diff --git a/apps/api/drizzle/0002_fair_sprite.sql b/apps/api/drizzle/0002_fair_sprite.sql new file mode 100644 index 00000000..f9305c71 --- /dev/null +++ b/apps/api/drizzle/0002_fair_sprite.sql @@ -0,0 +1,14 @@ +CREATE TABLE "user_preferences" ( + "user_id" text NOT NULL, + "key" text NOT NULL, + "value" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_preferences_user_id_key_pk" PRIMARY KEY("user_id","key") +); +--> statement-breakpoint +ALTER TABLE "audit_log" ADD COLUMN "integrity" text;--> statement-breakpoint +ALTER TABLE "audit_log" ADD COLUMN "request_id" text;--> statement-breakpoint +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_log_created_at_idx" ON "audit_log" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "audit_log_action_idx" ON "audit_log" USING btree ("action");--> statement-breakpoint +CREATE INDEX "audit_log_actor_id_idx" ON "audit_log" USING btree ("actor_id"); \ No newline at end of file diff --git a/apps/api/drizzle/0003_flimsy_dust.sql b/apps/api/drizzle/0003_flimsy_dust.sql new file mode 100644 index 00000000..12bc7dab --- /dev/null +++ b/apps/api/drizzle/0003_flimsy_dust.sql @@ -0,0 +1,7 @@ +ALTER TABLE "jobs" ADD COLUMN "delete_after" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "teams" ADD COLUMN "legal_hold" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "teams" ADD COLUMN "storage_quota" bigint;--> statement-breakpoint +ALTER TABLE "teams" ADD COLUMN "retention_hours" integer;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "legal_hold" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "storage_used" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "storage_quota" bigint; \ No newline at end of file diff --git a/apps/api/drizzle/0004_handy_warhawk.sql b/apps/api/drizzle/0004_handy_warhawk.sql new file mode 100644 index 00000000..cc8cd4be --- /dev/null +++ b/apps/api/drizzle/0004_handy_warhawk.sql @@ -0,0 +1,5 @@ +ALTER TABLE "roles" ADD COLUMN "tool_permissions" jsonb;--> statement-breakpoint +ALTER TABLE "sessions" ADD COLUMN "last_activity" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "totp_secret" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "totp_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "recovery_codes_hash" text; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..f63ef732 --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,918 @@ +{ + "id": "c24fd744-9739-43e0-8d2e-c17cd953a14e", + "prevId": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default API Key'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_log_created_at_idx": { + "name": "audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_id_idx": { + "name": "audit_log_actor_id_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_refs": { + "name": "input_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_refs": { + "name": "output_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bytes_in": { + "name": "bytes_in", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "jobs_created_at_idx": { + "name": "jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id_key_pk": { + "name": "user_preferences_user_id_key_pk", + "columns": ["user_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed", "canceled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/0003_snapshot.json b/apps/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..f68c6b37 --- /dev/null +++ b/apps/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,963 @@ +{ + "id": "ab6f41a5-43dd-40ba-9d10-a13711d3dc37", + "prevId": "c24fd744-9739-43e0-8d2e-c17cd953a14e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default API Key'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_log_created_at_idx": { + "name": "audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_id_idx": { + "name": "audit_log_actor_id_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_refs": { + "name": "input_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_refs": { + "name": "output_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bytes_in": { + "name": "bytes_in", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delete_after": { + "name": "delete_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "jobs_created_at_idx": { + "name": "jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "legal_hold": { + "name": "legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_quota": { + "name": "storage_quota", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "retention_hours": { + "name": "retention_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id_key_pk": { + "name": "user_preferences_user_id_key_pk", + "columns": ["user_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legal_hold": { + "name": "legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_used": { + "name": "storage_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_quota": { + "name": "storage_quota", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed", "canceled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/0004_snapshot.json b/apps/api/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000..6a749247 --- /dev/null +++ b/apps/api/drizzle/meta/0004_snapshot.json @@ -0,0 +1,994 @@ +{ + "id": "3c5d26d4-9789-462f-a07a-6894e29a403d", + "prevId": "ab6f41a5-43dd-40ba-9d10-a13711d3dc37", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default API Key'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_log_created_at_idx": { + "name": "audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_id_idx": { + "name": "audit_log_actor_id_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_refs": { + "name": "input_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_refs": { + "name": "output_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bytes_in": { + "name": "bytes_in", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delete_after": { + "name": "delete_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "jobs_created_at_idx": { + "name": "jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_permissions": { + "name": "tool_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "legal_hold": { + "name": "legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_quota": { + "name": "storage_quota", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "retention_hours": { + "name": "retention_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id_key_pk": { + "name": "user_preferences_user_id_key_pk", + "columns": ["user_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legal_hold": { + "name": "legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_used": { + "name": "storage_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_quota": { + "name": "storage_quota", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "recovery_codes_hash": { + "name": "recovery_codes_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed", "canceled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 9ba13992..69ce219c 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -15,6 +15,27 @@ "when": 1781189798348, "tag": "0001_jobs_spine", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1781338621449, + "tag": "0002_fair_sprite", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1781354362106, + "tag": "0003_flimsy_dust", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1781359444186, + "tag": "0004_handy_warhawk", + "breakpoints": true } ] } diff --git a/apps/api/package.json b/apps/api/package.json index f4eb0dec..e18cf14d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "@fastify/rate-limit": "^10.2.0", "@fastify/static": "^9.1.3", "@neplex/vectorizer": "^0.1.0", + "@node-saml/node-saml": "^5.1.0", "@scalar/fastify-api-reference": "^1.57.5", "@sentry/node": "^10.55.0", "@snapotter/ai": "workspace:*", @@ -43,6 +44,7 @@ "mupdf": "^1.27.0", "openid-client": "^6.8.4", "opentype.js": "^2.0.0", + "otpauth": "^9.5.1", "p-queue": "^9.3.0", "papaparse": "^5.5.3", "pdfkit": "^0.18.0", diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index f3ac03e2..139aac86 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -6,6 +6,7 @@ import { jsonb, pgEnum, pgTable, + primaryKey, text, timestamp, } from "drizzle-orm/pg-core"; @@ -28,12 +29,18 @@ export const users = pgTable("users", { authProvider: text("auth_provider").notNull().default("local"), externalId: text("external_id"), email: text("email"), + legalHold: boolean("legal_hold").notNull().default(false), + storageUsed: bigint("storage_used", { mode: "number" }).notNull().default(0), + storageQuota: bigint("storage_quota", { mode: "number" }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), + totpSecret: text("totp_secret"), + totpEnabled: boolean("totp_enabled").notNull().default(false), + recoveryCodesHash: text("recovery_codes_hash"), analyticsEnabled: boolean("analytics_enabled"), analyticsConsentShownAt: timestamp("analytics_consent_shown_at", { withTimezone: true }), analyticsConsentRemindAt: timestamp("analytics_consent_remind_at", { withTimezone: true }), @@ -42,6 +49,9 @@ export const users = pgTable("users", { export const teams = pgTable("teams", { id: text("id").primaryKey(), name: text("name").notNull().unique(), + legalHold: boolean("legal_hold").notNull().default(false), + storageQuota: bigint("storage_quota", { mode: "number" }), + retentionHours: integer("retention_hours"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), @@ -54,6 +64,7 @@ export const sessions = pgTable("sessions", { .references(() => users.id, { onDelete: "cascade" }), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), idToken: text("id_token"), + lastActivity: timestamp("last_activity", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .$defaultFn(() => new Date()), @@ -90,6 +101,7 @@ export const jobs = pgTable( .$defaultFn(() => new Date()), startedAt: timestamp("started_at", { withTimezone: true }), completedAt: timestamp("completed_at", { withTimezone: true }), + deleteAfter: timestamp("delete_after", { withTimezone: true }), }, (table) => [ index("jobs_created_at_idx").on(table.createdAt), @@ -124,25 +136,36 @@ export const pipelines = pgTable("pipelines", { .$defaultFn(() => new Date()), }); -export const auditLog = pgTable("audit_log", { - id: text("id").primaryKey(), - actorId: text("actor_id").references(() => users.id, { onDelete: "set null" }), - actorUsername: text("actor_username").notNull(), - action: text("action").notNull(), - targetType: text("target_type"), - targetId: text("target_id"), - details: jsonb("details").$type>(), - ipAddress: text("ip_address"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .$defaultFn(() => new Date()), -}); +export const auditLog = pgTable( + "audit_log", + { + id: text("id").primaryKey(), + actorId: text("actor_id").references(() => users.id, { onDelete: "set null" }), + actorUsername: text("actor_username").notNull(), + action: text("action").notNull(), + targetType: text("target_type"), + targetId: text("target_id"), + details: jsonb("details").$type>(), + ipAddress: text("ip_address"), + integrity: text("integrity"), + requestId: text("request_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .$defaultFn(() => new Date()), + }, + (table) => [ + index("audit_log_created_at_idx").on(table.createdAt), + index("audit_log_action_idx").on(table.action), + index("audit_log_actor_id_idx").on(table.actorId), + ], +); export const roles = pgTable("roles", { id: text("id").primaryKey(), name: text("name").notNull().unique(), description: text("description").notNull().default(""), permissions: jsonb("permissions").$type().notNull(), + toolPermissions: jsonb("tool_permissions").$type<{ mode: string; allowed: string[] } | null>(), isBuiltin: boolean("is_builtin").notNull().default(false), createdBy: text("created_by").references(() => users.id, { onDelete: "set null" }), createdAt: timestamp("created_at", { withTimezone: true }) @@ -169,3 +192,19 @@ export const userFiles = pgTable("user_files", { .notNull() .$defaultFn(() => new Date()), }); + +export const userPreferences = pgTable( + "user_preferences", + { + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + key: text("key").notNull(), + value: jsonb("value").$type>().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [primaryKey({ columns: [table.userId, table.key] })], +); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2a884166..611bc3ae 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { statfs } from "node:fs/promises"; import { join } from "node:path"; import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; @@ -13,7 +14,7 @@ import { runMigrations } from "./db/migrate.js"; import { startCancelListener, stopCancelListener } from "./jobs/cancel.js"; import { closeRedis, pingRedis } from "./jobs/connection.js"; import { closeFlowProducer, closeQueueEvents } from "./jobs/enqueue.js"; -import { closeQueues, queueCounts } from "./jobs/queues.js"; +import { closeQueues, perPoolHealth, queueCounts } from "./jobs/queues.js"; import { enqueueSystemJob, SYSTEM_JOBS, scheduleSystemJobs } from "./jobs/system-jobs.js"; import { closeWorkers, startWorkers } from "./jobs/worker.js"; import { captureException, initAnalytics, shutdownAnalytics } from "./lib/analytics.js"; @@ -21,6 +22,8 @@ import { shouldRunStartupCleanup } from "./lib/cleanup.js"; import { buildCsp } from "./lib/csp.js"; import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js"; +import { requestDuration } from "./lib/metrics.js"; +import { getSettingString } from "./lib/settings-helpers.js"; import { requirePermission } from "./permissions.js"; import { authMiddleware, @@ -29,7 +32,9 @@ import { ensureBuiltinRoles, ensureDefaultAdmin, } from "./plugins/auth.js"; +import { registerMfa } from "./plugins/mfa.js"; import { oidcRoutes } from "./plugins/oidc.js"; +import { registerSaml } from "./plugins/saml.js"; import { registerStatic } from "./plugins/static.js"; import { registerUpload } from "./plugins/upload.js"; import { adminOpsRoutes } from "./routes/admin-ops.js"; @@ -39,6 +44,7 @@ import { auditLogRoutes } from "./routes/audit-log.js"; import { registerBatchRoutes } from "./routes/batch.js"; import { configRoutes } from "./routes/config.js"; import { docsRoutes } from "./routes/docs.js"; +import { registerEnterpriseRoutes } from "./routes/enterprise/index.js"; import { registerFeatureRoutes } from "./routes/features.js"; import { registerFetchUrlsRoute } from "./routes/fetch-urls.js"; import { fileRoutes } from "./routes/files.js"; @@ -172,7 +178,16 @@ await startCancelListener(); ensureAiDirs(); recoverInterruptedInstalls(); +function parseTrustProxy(value: string): boolean | number | string { + if (value === "true") return true; + if (value === "false") return false; + const asNum = Number(value); + if (!Number.isNaN(asNum)) return asNum; + return value; // CIDR list +} + const app = Fastify({ + genReqId: (req) => (req.headers["x-request-id"] as string) ?? randomUUID(), logger: { level: env.LOG_LEVEL, transport: { @@ -194,7 +209,7 @@ const app = Fastify({ redact: ["req.headers.authorization", "req.headers.cookie"], }, bodyLimit: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : 1073741824, - trustProxy: env.TRUST_PROXY, + trustProxy: parseTrustProxy(env.TRUST_PROXY), routerOptions: { maxParamLength: 500 }, }); @@ -246,6 +261,7 @@ await app.register(cors, { // HTTP so it is safe (and desirable) to send it in dev/staging too. CSP catches // injection issues early when applied during development. app.addHook("onSend", async (_request, reply) => { + reply.header("x-request-id", _request.id); reply.header("X-Content-Type-Options", "nosniff"); reply.header("X-Frame-Options", "DENY"); reply.header("X-XSS-Protection", "0"); @@ -255,6 +271,26 @@ app.addHook("onSend", async (_request, reply) => { reply.header("Content-Security-Policy", buildCsp(_request.url.startsWith("/api/docs"))); }); +// Record HTTP request duration for Prometheus (bounded cardinality: 5 route groups * 5 status classes) +app.addHook("onResponse", (request, reply, done) => { + const duration = reply.elapsedTime / 1000; + + const url = request.url; + let routeGroup = "other"; + if (url.startsWith("/api/v1/tools/") || url.startsWith("/api/v1/jobs/")) routeGroup = "tools"; + else if (url.startsWith("/api/auth/") || url.startsWith("/api/v1/enterprise/")) + routeGroup = "auth"; + else if (url.startsWith("/api/v1/admin/") || url.startsWith("/api/v1/settings")) + routeGroup = "admin"; + else if (url.startsWith("/api/v1/files")) routeGroup = "files"; + else if (url.startsWith("/api/v1/scim/")) routeGroup = "scim"; + + const statusClass = `${Math.floor(reply.statusCode / 100)}xx`; + + requestDuration.observe({ route_group: routeGroup, status_class: statusClass }, duration); + done(); +}); + // Always register rate-limit plugin so per-route limits (login brute-force protection) work. // max=0 means "unlimited" (50k/min) -- @fastify/rate-limit treats literal 0 as "block all". await app.register(rateLimit, { @@ -279,18 +315,33 @@ await app.register(cookie, { hook: "onRequest", }); +// IP allowlist (enterprise -- must run before auth to reject early) +import { registerIpAllowlist } from "./plugins/ip-allowlist.js"; +import { registerPerUserRateLimit } from "./plugins/per-user-rate-limit.js"; + +await registerIpAllowlist(app); + // Public config routes (no auth required) await configRoutes(app); // Auth middleware (must be registered before routes it protects) await authMiddleware(app); +// Per-user rate limiting (after auth so request.user is populated) +await registerPerUserRateLimit(app); + // Auth routes await authRoutes(app); // OIDC routes await oidcRoutes(app); +// SAML routes +await registerSaml(app); + +// MFA routes (TOTP enrollment, verification, disable) +await registerMfa(app); + // File upload/download routes await fileRoutes(app); @@ -339,9 +390,22 @@ await rolesRoutes(app); // Admin ops routes (runtime log level, Prometheus metrics) await adminOpsRoutes(app); +// Enterprise routes (license-gated features) +await registerEnterpriseRoutes(app); + // API docs (Scalar) await docsRoutes(app); +// Disk space check for readiness probe (local storage mode only) +async function checkDiskSpace(path: string, minBytes: number): Promise { + try { + const stats = await statfs(path); + return stats.bfree * stats.bsize > minBytes; + } catch { + return true; // Path doesn't exist or not applicable -- skip check + } +} + // Public health check (checks core dependencies) app.get("/api/v1/health", async (_request, reply) => { let dbOk = false; @@ -373,12 +437,41 @@ app.get("/api/v1/admin/health", async (request, reply) => { /* db unreachable */ } let queueStats = { active: 0, pending: 0 }; + let pools: Record = {}; try { const counts = await queueCounts(); queueStats = { active: counts.active, pending: counts.waiting }; + pools = await perPoolHealth(); } catch { /* redis unreachable */ } + + // Storage total across all users + let libraryStorage = "0"; + try { + const storageResult = await db + .select({ + totalBytes: sql`coalesce(sum(${schema.users.storageUsed}), 0)::text`, + }) + .from(schema.users); + libraryStorage = storageResult[0]?.totalBytes ?? "0"; + } catch { + /* db error */ + } + + // Backup recency + let lastBackup: string | null = null; + try { + const backupResult = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, "backup_last_completed")) + .limit(1); + lastBackup = backupResult.length > 0 ? backupResult[0].value : null; + } catch { + /* db error */ + } + return { status: dbOk ? "healthy" : "degraded", version: APP_VERSION, @@ -386,6 +479,9 @@ app.get("/api/v1/admin/health", async (request, reply) => { storage: { mode: env.STORAGE_MODE, available: "N/A" }, database: dbOk ? "ok" : "error", queue: queueStats, + pools, + libraryStorage, + lastBackup, ai: { gpu: isGpuAvailable(), dispatcher: getDispatcherStatus() }, enterprise: enterpriseLicense ? { active: true, org: enterpriseLicense.org, plan: enterpriseLicense.plan } @@ -403,6 +499,25 @@ app.get("/api/v1/config/auth", async () => { config.oidcProviderName = env.OIDC_PROVIDER_NAME || null; config.oidcLoginUrl = "/api/auth/oidc/login"; } + + // SAML SSO requires both env flag and enterprise license + let samlLicensed = false; + if (env.SAML_ENABLED) { + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + samlLicensed = isFeatureEnabled("saml_sso"); + } catch { + // Enterprise package not available + } + } + if (env.SAML_ENABLED && samlLicensed) { + config.samlEnabled = true; + config.samlProviderName = env.SAML_PROVIDER_NAME || "SSO"; + config.samlLoginUrl = "/api/auth/saml/login"; + } + + config.ssoEnforced = (await getSettingString("ssoEnforcement", "false")) === "true"; + return config; }); @@ -421,8 +536,28 @@ app.get("/api/v1/readyz", async (_request, reply) => { } catch { /* redis unreachable */ } - const ok = postgres && redis; - return reply.code(ok ? 200 : 503).send({ ok, postgres, redis }); + + // Disk space: fail readiness if below 500 MB on storage paths (local mode only) + const diskOk = + env.STORAGE_MODE !== "s3" + ? (await checkDiskSpace(env.WORKSPACE_PATH, 500 * 1024 * 1024)) && + (await checkDiskSpace(env.FILES_STORAGE_PATH, 500 * 1024 * 1024)) + : true; + + // S3 reachability (S3 mode only) + let s3Ok = true; + if (env.STORAGE_MODE === "s3") { + try { + const { loadS3Storage } = await import("@snapotter/enterprise"); + const s3 = await loadS3Storage(); + await s3.checkConnection(); + } catch { + s3Ok = false; + } + } + + const ok = postgres && redis && diskOk && s3Ok; + return reply.code(ok ? 200 : 503).send({ ok, postgres, redis, disk: diskOk, s3: s3Ok }); }); // Cancel a job (authenticated) diff --git a/apps/api/src/jobs/alert-evaluator.ts b/apps/api/src/jobs/alert-evaluator.ts new file mode 100644 index 00000000..acbc9278 --- /dev/null +++ b/apps/api/src/jobs/alert-evaluator.ts @@ -0,0 +1,124 @@ +/** + * Alert condition evaluator. + * + * A periodic system job (every 60s) that checks several health/security + * conditions and delivers alerts to webhook destinations of type "alerts". + * + * Conditions checked: + * - Disk space below threshold (< 1 GB) + * - Auth anomaly (> 20 login failures in 5 minutes) + * - Backup staleness (> 48 hours since last completed backup) + * - License expiring (< 30 days remaining) + */ +import { statfs } from "node:fs/promises"; +import { and, eq, gte, sql } from "drizzle-orm"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { getSettingString } from "../lib/settings-helpers.js"; + +export async function evaluateAlerts(): Promise { + // 1. Read webhook destinations from settings + const destJson = await getSettingString("webhook_destinations", "[]"); + let destinations: { url: string; authHeader: string; enabled: boolean; type: string }[]; + try { + destinations = JSON.parse(destJson); + } catch { + return; + } + + // 2. Filter to type = "alerts" and enabled = true + const alertDests = destinations.filter((d) => d.enabled && d.type === "alerts"); + if (alertDests.length === 0) return; + + // 3. Check conditions + const alerts: Record[] = []; + + // a. Disk space below threshold (< 1 GB) + try { + const stats = await statfs(env.WORKSPACE_PATH); + const freeGb = (stats.bfree * stats.bsize) / 1024 ** 3; + if (freeGb < 1) { + alerts.push({ condition: "disk_space_low", freeGb, threshold: 1 }); + } + } catch { + // statfs may fail on some filesystems; skip check + } + + // b. Auth anomaly (> 20 failures in last 5 minutes) + try { + const recentFailures = await db + .select({ count: sql`count(*)::int` }) + .from(schema.auditLog) + .where( + and( + eq(schema.auditLog.action, "LOGIN_FAILED"), + gte(schema.auditLog.createdAt, new Date(Date.now() - 5 * 60 * 1000)), + ), + ); + if (recentFailures[0].count > 20) { + alerts.push({ + condition: "auth_anomaly", + failedLogins: recentFailures[0].count, + windowMinutes: 5, + }); + } + } catch { + // Query may fail if audit_log is unavailable + } + + // c. Backup staleness (> 48 hours) + try { + const backupResult = await getSettingString("backup_last_completed", ""); + if (backupResult) { + const lastBackup = JSON.parse(backupResult); + const ageHours = (Date.now() - new Date(lastBackup.timestamp).getTime()) / 3_600_000; + if (ageHours > 48) { + alerts.push({ condition: "backup_stale", ageHours, threshold: 48 }); + } + } else { + alerts.push({ condition: "backup_never_run" }); + } + } catch { + // Backup check is best-effort + } + + // d. License expiration (< 30 days) + try { + const { getActiveLicense } = await import("@snapotter/enterprise"); + const license = getActiveLicense(); + if (license?.expiresAt) { + const daysLeft = (new Date(license.expiresAt).getTime() - Date.now()) / 86_400_000; + if (daysLeft < 30) { + alerts.push({ condition: "license_expiring", daysLeft: Math.floor(daysLeft) }); + } + } + } catch { + // Enterprise package not available; skip + } + + // 4. If no alerts triggered, nothing to do + if (alerts.length === 0) return; + + // 5. Deliver to each enabled "alerts" webhook + for (const dest of alertDests) { + let authHeader = dest.authHeader; + + // Decrypt auth header if encrypted + if (authHeader) { + try { + const { isEncrypted, decrypt } = await import("../lib/encryption.js"); + if (isEncrypted(authHeader) && env.DATA_ENCRYPTION_KEY) { + const decrypted = await decrypt(authHeader, env.DATA_ENCRYPTION_KEY); + authHeader = decrypted ?? ""; + } + } catch { + // Use raw value if decryption fails + } + } + + const { deliverWebhook } = await import("../lib/webhook-delivery.js"); + await deliverWebhook(dest.url, authHeader, alerts, { maxRetries: 1 }); + } + + console.log(`Alert evaluation complete: ${alerts.length} alert(s) delivered`); +} diff --git a/apps/api/src/jobs/audit-archive.ts b/apps/api/src/jobs/audit-archive.ts new file mode 100644 index 00000000..33368037 --- /dev/null +++ b/apps/api/src/jobs/audit-archive.ts @@ -0,0 +1,211 @@ +/** + * Audit log archival job. + * + * Archives old audit log entries to compressed NDJSON files using a crash-safe + * 5-state machine. Runs monthly, gated behind the enterprise license + * (tamper_resistant_audit feature). + * + * State machine: + * PENDING -- record the date boundary + * EXPORTING -- write old rows to gzipped NDJSON + * EXPORTED -- verify row count + checksum + * PURGING -- delete archived rows from active table + * COMPLETE -- clean up state key + * + * State is persisted in the settings table under "audit_archival_state" so that + * a crash between export and purge does not lose data. The next run resumes + * from the last completed state. + */ +import { createHash } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import { mkdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createGzip } from "node:zlib"; +import { eq, lt } from "drizzle-orm"; +import type { FastifyBaseLogger } from "fastify"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { upsertSetting } from "../lib/settings-helpers.js"; + +type ArchivalState = "PENDING" | "EXPORTING" | "EXPORTED" | "PURGING" | "COMPLETE"; + +const STATE_KEY = "audit_archival_state"; + +interface ArchivalRun { + state: ArchivalState; + dateBoundary: string; + outputPath: string; + rowCount: number; + checksum: string; +} + +// -- Settings helpers ---------------------------------------------------------- + +async function readSettingValue(key: string): Promise { + const [row] = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, key)); + return row?.value ?? null; +} + +async function deleteSetting(key: string): Promise { + await db.delete(schema.settings).where(eq(schema.settings.key, key)); +} + +// -- Archive directory -------------------------------------------------------- + +function getArchiveDir(): string { + // Derive archive dir from FILES_STORAGE_PATH parent (./data/files -> ./data/audit-archives) + const filesPath = env.FILES_STORAGE_PATH; + const dataDir = join(filesPath, ".."); + return join(dataDir, "audit-archives"); +} + +// -- Core archival logic ------------------------------------------------------ + +export async function runAuditArchive(log?: FastifyBaseLogger): Promise { + // 1. Check enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("tamper_resistant_audit"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return; + } + + // 2. Read archive months setting + const monthsStr = await readSettingValue("auditArchiveMonths"); + const archiveMonths = monthsStr ? parseInt(monthsStr, 10) : 0; + if (!archiveMonths || archiveMonths <= 0) { + return; + } + + // 3. Resume from persisted state or start fresh + const existingState = await readSettingValue(STATE_KEY); + let run: ArchivalRun; + + if (existingState) { + try { + run = JSON.parse(existingState) as ArchivalRun; + log?.info({ state: run.state }, "Resuming audit archival from persisted state"); + } catch { + // Corrupt state -- start fresh + await deleteSetting(STATE_KEY); + run = freshRun(archiveMonths); + } + } else { + run = freshRun(archiveMonths); + } + + // 4. State machine -- sequential ifs give fall-through semantics: + // a fresh run progresses through all states in one pass, and a + // resumed run picks up from wherever it left off. + + if (run.state === "PENDING") { + await upsertSetting(STATE_KEY, JSON.stringify({ ...run, state: "EXPORTING" })); + run.state = "EXPORTING"; + } + + if (run.state === "EXPORTING") { + const archiveDir = getArchiveDir(); + await mkdir(archiveDir, { recursive: true }); + + const timestamp = run.dateBoundary.replace(/[:.]/g, "-"); + const outputPath = join(archiveDir, `audit-archive-${timestamp}.ndjson.gz`); + run.outputPath = outputPath; + + const boundary = new Date(run.dateBoundary); + const rows = await db + .select() + .from(schema.auditLog) + .where(lt(schema.auditLog.createdAt, boundary)); + + if (rows.length === 0) { + log?.info("No audit rows older than boundary, nothing to archive"); + await deleteSetting(STATE_KEY); + return; + } + + // Write compressed NDJSON and compute checksum + const hash = createHash("sha256"); + const lines: string[] = []; + for (const row of rows) { + const line = JSON.stringify(row); + lines.push(line); + hash.update(line); + hash.update("\n"); + } + + const readable = Readable.from(lines.map((l) => `${l}\n`)); + const gzip = createGzip(); + const output = createWriteStream(outputPath); + await pipeline(readable, gzip, output); + + run.rowCount = rows.length; + run.checksum = hash.digest("hex"); + run.state = "EXPORTED"; + await upsertSetting(STATE_KEY, JSON.stringify(run)); + } + + if (run.state === "EXPORTED") { + // Verify the archive file exists and checksum is recorded + try { + await stat(run.outputPath); + } catch { + log?.error({ path: run.outputPath }, "Archive file missing after export, aborting"); + await deleteSetting(STATE_KEY); + return; + } + + if (!run.checksum || run.rowCount <= 0) { + log?.error("Invalid archival state: missing checksum or rowCount"); + await deleteSetting(STATE_KEY); + return; + } + + log?.info( + { rowCount: run.rowCount, checksum: run.checksum, path: run.outputPath }, + "Archive verified", + ); + + run.state = "PURGING"; + await upsertSetting(STATE_KEY, JSON.stringify(run)); + } + + if (run.state === "PURGING") { + const boundary = new Date(run.dateBoundary); + const result = await db.delete(schema.auditLog).where(lt(schema.auditLog.createdAt, boundary)); + + log?.info( + { purgedRows: (result as { rowCount?: number }).rowCount ?? run.rowCount }, + "Purged archived audit rows", + ); + + run.state = "COMPLETE"; + await upsertSetting(STATE_KEY, JSON.stringify(run)); + } + + if (run.state === "COMPLETE") { + await deleteSetting(STATE_KEY); + log?.info({ rowCount: run.rowCount, outputPath: run.outputPath }, "Audit archival complete"); + } +} + +function freshRun(archiveMonths: number): ArchivalRun { + const boundary = new Date(); + boundary.setMonth(boundary.getMonth() - archiveMonths); + + return { + state: "PENDING", + dateBoundary: boundary.toISOString(), + outputPath: "", + rowCount: 0, + checksum: "", + }; +} diff --git a/apps/api/src/jobs/enqueue.ts b/apps/api/src/jobs/enqueue.ts index d9254f2e..bead0848 100644 --- a/apps/api/src/jobs/enqueue.ts +++ b/apps/api/src/jobs/enqueue.ts @@ -6,6 +6,7 @@ * until the worker produces a result or the sync-wait window expires. */ import { FlowProducer, type Job, QueueEvents } from "bullmq"; +import { eq } from "drizzle-orm"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { createRedisConnection } from "./connection.js"; @@ -76,6 +77,11 @@ export async function enqueueToolJob(data: ToolJobData): Promise, }); + // Fire-and-forget: compute deleteAfter from team retention override + if (data.userId) { + void computeDeleteAfter(data.jobId, data.userId).catch(() => {}); + } + const queue = getQueue(data.pool); const job = await queue.add(data.toolId, { ...data, jobId: data.jobId }, { jobId: data.jobId }); return job; @@ -108,3 +114,43 @@ export async function waitForJob( throw err; // real failure } } + +// ── Per-team retention override ──────────────────────────────── + +/** + * Compute and set `deleteAfter` on a job row based on the owning user's + * team retention setting. Only applies when the enterprise + * `team_retention_overrides` feature is enabled. Fire-and-forget; failures + * never block job creation. + */ +async function computeDeleteAfter(jobId: string, userId: string): Promise { + let isTeamRetentionEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + isTeamRetentionEnabled = isFeatureEnabled("team_retention_overrides"); + } catch {} + + if (!isTeamRetentionEnabled) return; + + const userRow = await db + .select({ team: schema.users.team }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + + if (!userRow.length || !userRow[0].team) return; + + const teamRow = await db + .select({ retentionHours: schema.teams.retentionHours }) + .from(schema.teams) + .where(eq(schema.teams.id, userRow[0].team)) + .limit(1); + + const retentionHours = + teamRow.length && teamRow[0].retentionHours !== null + ? teamRow[0].retentionHours + : env.FILE_MAX_AGE_HOURS; + + const deleteAfter = new Date(Date.now() + retentionHours * 60 * 60 * 1000); + await db.update(schema.jobs).set({ deleteAfter }).where(eq(schema.jobs.id, jobId)); +} diff --git a/apps/api/src/jobs/gdpr-export.ts b/apps/api/src/jobs/gdpr-export.ts new file mode 100644 index 00000000..b5e99e90 --- /dev/null +++ b/apps/api/src/jobs/gdpr-export.ts @@ -0,0 +1,105 @@ +/** + * GDPR user data export job. + * + * Collects all user data (profile, files metadata, job history, audit log), + * copies library file contents, and produces a ZIP archive stored in + * object storage under `outputs//gdpr-export.zip`. + */ +import { PassThrough } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import archiver from "archiver"; +import { eq } from "drizzle-orm"; +import { db, schema } from "../db/index.js"; +import { readStoredFile } from "../lib/file-storage.js"; +import { putObject } from "../lib/object-storage.js"; + +export async function gdprExportJob(userId: string, jobId: string): Promise<{ outputRef: string }> { + // 1. Fetch user profile (exclude passwordHash) + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); + if (!user) throw new Error(`User ${userId} not found`); + + const { passwordHash: _, ...profile } = user; + + // 2. Fetch all user's files metadata + const files = await db.select().from(schema.userFiles).where(eq(schema.userFiles.userId, userId)); + + // 3. Fetch job metadata + const jobs = await db.select().from(schema.jobs).where(eq(schema.jobs.userId, userId)); + + // 4. Fetch audit log entries where user is the actor + const auditEntries = await db + .select() + .from(schema.auditLog) + .where(eq(schema.auditLog.actorId, userId)); + + // 5. Build a ZIP archive in memory via archiver + const archive = archiver("zip", { zlib: { level: 6 } }); + const chunks: Buffer[] = []; + const passthrough = new PassThrough(); + passthrough.on("data", (chunk: Buffer) => chunks.push(chunk)); + + const pipelineDone = pipeline(archive, passthrough); + + archive.append(JSON.stringify(profile, null, 2), { name: "profile.json" }); + + archive.append( + JSON.stringify( + files.map((f) => ({ + ...f, + createdAt: f.createdAt?.toISOString(), + })), + null, + 2, + ), + { name: "files.json" }, + ); + + archive.append( + JSON.stringify( + jobs.map((j) => ({ + ...j, + createdAt: j.createdAt?.toISOString(), + startedAt: j.startedAt?.toISOString(), + completedAt: j.completedAt?.toISOString(), + deleteAfter: j.deleteAfter?.toISOString(), + })), + null, + 2, + ), + { name: "jobs.json" }, + ); + + archive.append( + JSON.stringify( + auditEntries.map((a) => ({ + ...a, + createdAt: a.createdAt?.toISOString(), + })), + null, + 2, + ), + { name: "audit-log.json" }, + ); + + // 6. Copy library file contents into the ZIP + for (const file of files) { + try { + const buffer = await readStoredFile(file.storedName); + archive.append(buffer, { + name: `library-files/${file.id}_${file.originalName}`, + }); + } catch { + // File may have been cleaned up; skip silently + } + } + + await archive.finalize(); + await pipelineDone; + + // 7. Write ZIP to object storage + const zipBuffer = Buffer.concat(chunks); + const outputRef = `outputs/${jobId}/gdpr-export.zip`; + await putObject(outputRef, zipBuffer); + + return { outputRef }; +} diff --git a/apps/api/src/jobs/queues.ts b/apps/api/src/jobs/queues.ts index 5bd65c47..4a0d8f10 100644 --- a/apps/api/src/jobs/queues.ts +++ b/apps/api/src/jobs/queues.ts @@ -69,3 +69,38 @@ export async function perPoolCounts(): Promise +> { + const result: Record< + string, + { active: number; waiting: number; failed: number; oldestWaitingMs: number | null } + > = {}; + for (const pool of POOLS) { + const q = queues.get(pool); + if (!q) { + result[pool] = { active: 0, waiting: 0, failed: 0, oldestWaitingMs: null }; + continue; + } + const counts = await q.getJobCounts("active", "waiting", "failed"); + let oldestWaitingMs: number | null = null; + if ((counts.waiting ?? 0) > 0) { + const jobs = await q.getJobs(["waiting"], 0, 0, true); + if (jobs.length > 0 && jobs[0]) { + oldestWaitingMs = Date.now() - jobs[0].timestamp; + } + } + result[pool] = { + active: counts.active ?? 0, + waiting: counts.waiting ?? 0, + failed: counts.failed ?? 0, + oldestWaitingMs, + }; + } + return result as Record< + Pool, + { active: number; waiting: number; failed: number; oldestWaitingMs: number | null } + >; +} diff --git a/apps/api/src/jobs/siem-forward.ts b/apps/api/src/jobs/siem-forward.ts new file mode 100644 index 00000000..44940e1a --- /dev/null +++ b/apps/api/src/jobs/siem-forward.ts @@ -0,0 +1,111 @@ +/** + * SIEM forwarding via the legacy siem_config settings key. + * Phase 4 introduced a unified webhook system (webhook_destinations), + * which also supports type: "siem" destinations. Currently these coexist -- + * a future release will migrate siem_config to the unified system. + * For now, admins configure SIEM in either system (not both). + * + * Reads unforwarded audit log entries and delivers them to the configured + * SIEM endpoint via the webhook delivery module. Implements a circuit + * breaker (5 consecutive failures = disabled until manual reset) and a + * cursor-based approach to avoid re-sending events. + * + * State keys in the settings table: + * - siem_last_forwarded_at: cursor (last successfully forwarded createdAt timestamp) + * - siem_consecutive_failures: circuit breaker counter + */ +import { asc, eq, gte } from "drizzle-orm"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { decrypt, isEncrypted } from "../lib/encryption.js"; +import { upsertSetting } from "../lib/settings-helpers.js"; +import { deliverWebhook } from "../lib/webhook-delivery.js"; +import { readSiemConfig } from "../routes/enterprise/siem.js"; + +const BATCH_LIMIT = 500; +const CIRCUIT_BREAKER_THRESHOLD = 5; +const CURSOR_KEY = "siem_last_forwarded_at"; +const FAILURES_KEY = "siem_consecutive_failures"; + +async function readSettingValue(key: string): Promise { + const [row] = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, key)); + return row?.value ?? null; +} + +export async function runSiemForward(): Promise<{ forwarded: number } | void> { + // 1. Read SIEM config + const config = await readSiemConfig(); + if (!config || !config.enabled || !config.webhookUrl) { + return; + } + + // 2. Circuit breaker check + const failureCountStr = await readSettingValue(FAILURES_KEY); + const failureCount = failureCountStr ? parseInt(failureCountStr, 10) : 0; + if (failureCount >= CIRCUIT_BREAKER_THRESHOLD) { + console.warn( + `SIEM forwarding circuit breaker open: ${failureCount} consecutive failures. ` + + "Reset siem_consecutive_failures to 0 in settings to re-enable.", + ); + return; + } + + // 3. Read cursor + const cursor = await readSettingValue(CURSOR_KEY); + + // 4. Query audit_log for new rows (createdAt-based cursor; may re-deliver + // boundary events on restart, but SIEMs handle idempotent ingestion) + const cursorDate = cursor ? new Date(cursor) : null; + const conditions = cursorDate ? gte(schema.auditLog.createdAt, cursorDate) : undefined; + const rows = await db + .select() + .from(schema.auditLog) + .where(conditions) + .orderBy(asc(schema.auditLog.createdAt)) + .limit(BATCH_LIMIT); + + if (rows.length === 0) { + return; + } + + // 5. Decrypt auth header if encrypted + let authHeader = config.authHeader; + if (authHeader && isEncrypted(authHeader) && env.DATA_ENCRYPTION_KEY) { + const decrypted = await decrypt(authHeader, env.DATA_ENCRYPTION_KEY); + authHeader = decrypted ?? ""; + } + + // 6. Map rows to SIEM event payload + const events = rows.map((row) => ({ + timestamp: row.createdAt.toISOString(), + event: row.action, + actorId: row.actorId, + actorUsername: row.actorUsername, + targetType: row.targetType, + targetId: row.targetId, + ip: row.ipAddress, + details: row.details, + })); + + // 7. Deliver via webhook + const result = await deliverWebhook(config.webhookUrl, authHeader, events); + + // 8. Update state based on result + if (result.success) { + const lastRow = rows[rows.length - 1]; + await upsertSetting(CURSOR_KEY, lastRow.createdAt.toISOString()); + if (failureCount > 0) { + await upsertSetting(FAILURES_KEY, "0"); + } + return { forwarded: rows.length }; + } + + // Failure: increment circuit breaker + await upsertSetting(FAILURES_KEY, String(failureCount + 1)); + console.error( + `SIEM forwarding failed (attempt ${failureCount + 1}/${CIRCUIT_BREAKER_THRESHOLD}): ${result.error}`, + ); +} diff --git a/apps/api/src/jobs/storage-reconciliation.ts b/apps/api/src/jobs/storage-reconciliation.ts new file mode 100644 index 00000000..33ccaaeb --- /dev/null +++ b/apps/api/src/jobs/storage-reconciliation.ts @@ -0,0 +1,53 @@ +/** + * Weekly storage reconciliation job. + * + * Recomputes each user's storageUsed counter from the actual sum of + * their userFiles rows and corrects any drift caused by race conditions, + * crashes, or bugs. Scheduled for 3 AM Sunday via system-jobs. + */ +import { and, eq, notInArray, sql } from "drizzle-orm"; +import { db, schema } from "../db/index.js"; + +export async function storageReconciliationJob(): Promise { + // Sum actual file sizes per user + const actual = await db + .select({ + userId: schema.userFiles.userId, + totalSize: sql`coalesce(sum(${schema.userFiles.size}), 0)::int`, + }) + .from(schema.userFiles) + .groupBy(schema.userFiles.userId); + + let updated = 0; + for (const row of actual) { + if (!row.userId) continue; + const result = await db + .update(schema.users) + .set({ storageUsed: row.totalSize }) + .where( + and(eq(schema.users.id, row.userId), sql`${schema.users.storageUsed} != ${row.totalSize}`), + ); + if (result.rowCount) updated++; + } + + // Zero out users who have no files but a nonzero storageUsed counter + const usersWithFiles = actual.filter((r) => r.userId != null).map((r) => r.userId as string); + if (usersWithFiles.length > 0) { + await db + .update(schema.users) + .set({ storageUsed: 0 }) + .where( + and(sql`${schema.users.storageUsed} > 0`, notInArray(schema.users.id, usersWithFiles)), + ); + } else { + // No users have files -- zero everyone + await db + .update(schema.users) + .set({ storageUsed: 0 }) + .where(sql`${schema.users.storageUsed} > 0`); + } + + console.log( + `Storage reconciliation complete: ${actual.length} users checked, ${updated} corrected`, + ); +} diff --git a/apps/api/src/jobs/system-jobs.ts b/apps/api/src/jobs/system-jobs.ts index fa093197..9a21f110 100644 --- a/apps/api/src/jobs/system-jobs.ts +++ b/apps/api/src/jobs/system-jobs.ts @@ -10,17 +10,24 @@ * calling runSystemJob); anything else is a bug. */ import type { Job } from "bullmq"; -import { inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, lt, sql } from "drizzle-orm"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { getMaxAgeMs } from "../lib/cleanup.js"; import { deletePrefix, listJobDirs, type ObjectInfo } from "../lib/object-storage.js"; +import { runAuditArchive } from "./audit-archive.js"; import { getQueue } from "./queues.js"; +import { runSiemForward } from "./siem-forward.js"; export const SYSTEM_JOBS = { storageTtl: "system:storage-ttl", sessionPurge: "system:session-purge", retention: "system:retention", + siemForward: "system:siem-forward", + auditArchive: "system:audit-archive", + storageReconciliation: "system:storage-reconciliation", + gdprExport: "system:gdpr-export", + alertEvaluator: "system:alert-evaluator", } as const; // -- Scheduling --------------------------------------------------------------- @@ -39,6 +46,17 @@ export async function scheduleSystemJobs(): Promise { } await q.upsertJobScheduler(SYSTEM_JOBS.sessionPurge, { every: 60 * 60_000 }); await q.upsertJobScheduler(SYSTEM_JOBS.retention, { every: 6 * 60 * 60_000 }); + await q.upsertJobScheduler(SYSTEM_JOBS.siemForward, { every: 30_000 }); + // Monthly: 2:00 AM on the 1st of each month + await q.upsertJobScheduler(SYSTEM_JOBS.auditArchive, { + pattern: "0 2 1 * *", + }); + // Weekly: 3:00 AM Sunday -- reconcile storageUsed counters + await q.upsertJobScheduler(SYSTEM_JOBS.storageReconciliation, { + pattern: "0 3 * * 0", + }); + // Alert evaluator: every 60 seconds + await q.upsertJobScheduler(SYSTEM_JOBS.alertEvaluator, { every: 60_000 }); } /** Enqueue a one-shot system job (e.g. startup cleanup trigger). */ @@ -58,6 +76,33 @@ export async function runSystemJob(job: Job): Promise { return db.execute(sql`DELETE FROM sessions WHERE expires_at < now()`); case SYSTEM_JOBS.retention: return retentionSweep(); + case SYSTEM_JOBS.siemForward: + return runSiemForward(); + case SYSTEM_JOBS.auditArchive: + return runAuditArchive(); + case SYSTEM_JOBS.storageReconciliation: { + const { storageReconciliationJob } = await import("./storage-reconciliation.js"); + return storageReconciliationJob(); + } + case SYSTEM_JOBS.gdprExport: { + const { gdprExportJob } = await import("./gdpr-export.js"); + const exportData = job.data as unknown as { userId: string; jobId: string }; + const { outputRef } = await gdprExportJob(exportData.userId, exportData.jobId); + // Update the job row with the output reference + await db + .update(schema.jobs) + .set({ + status: "completed", + completedAt: new Date(), + outputRefs: [outputRef], + }) + .where(eq(schema.jobs.id, exportData.jobId)); + return { outputRef }; + } + case SYSTEM_JOBS.alertEvaluator: { + const { evaluateAlerts } = await import("./alert-evaluator.js"); + return evaluateAlerts(); + } default: // batch-finalize runs on the system pool too but is routed by the // worker before calling runSystemJob. Anything else is a bug. @@ -99,8 +144,61 @@ export function decideExpiry( } async function storageTtlSweep(): Promise<{ removed: number; failed: number }> { + // Build set of user IDs under legal hold (direct or via team) once per sweep + const heldUserRows = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.legalHold, true)); + const heldUserIds = new Set(heldUserRows.map((r) => r.id)); + + const heldTeamRows = await db + .select({ id: schema.teams.id }) + .from(schema.teams) + .where(eq(schema.teams.legalHold, true)); + if (heldTeamRows.length > 0) { + const teamUsers = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where( + inArray( + schema.users.team, + heldTeamRows.map((r) => r.id), + ), + ); + for (const u of teamUsers) heldUserIds.add(u.id); + } + + // --- Per-job deleteAfter sweep (team retention overrides) --- + // Runs regardless of the global TTL; deleteAfter is an absolute deadline. + let deleteAfterCleaned = 0; + try { + const expiredJobs = await db + .select({ id: schema.jobs.id, userId: schema.jobs.userId }) + .from(schema.jobs) + .where(and(isNotNull(schema.jobs.deleteAfter), lt(schema.jobs.deleteAfter, new Date()))); + + for (const job of expiredJobs) { + // Skip jobs belonging to users under legal hold + if (job.userId && heldUserIds.has(job.userId)) continue; + try { + await deletePrefix(`uploads/${job.id}`); + await deletePrefix(`outputs/${job.id}`); + deleteAfterCleaned++; + } catch { + // Directory may not exist + } + } + + if (deleteAfterCleaned > 0) { + console.log(`Storage TTL: cleaned up ${deleteAfterCleaned} jobs by deleteAfter`); + } + } catch { + // deleteAfter sweep is best-effort + } + + // --- Global TTL sweep --- const maxAgeMs = await getMaxAgeMs(); - if (maxAgeMs <= 0) return { removed: 0, failed: 0 }; + if (maxAgeMs <= 0) return { removed: deleteAfterCleaned, failed: 0 }; const cutoffMs = Date.now() - maxAgeMs; const uploadDirs = await listJobDirs("uploads"); @@ -127,10 +225,28 @@ async function storageTtlSweep(): Promise<{ removed: number; failed: number }> { } } + // Batch-lookup userId for legal hold check (only if any users are held) + const jobUserMap = new Map(); + if (heldUserIds.size > 0 && allDirs.length > 0) { + const allJobIds = [...new Set(allDirs.map((d) => d.key.split("/")[1]))]; + if (allJobIds.length > 0) { + const userRows = await db + .select({ id: schema.jobs.id, userId: schema.jobs.userId }) + .from(schema.jobs) + .where(inArray(schema.jobs.id, allJobIds)); + for (const r of userRows) jobUserMap.set(r.id, r.userId); + } + } + let removed = 0; const errors: string[] = []; for (const dir of allDirs) { if (decideExpiry(dir, cutoffMs, rowsById) === "expired") { + // Skip deletion if the job's user is under legal hold + const jobId = dir.key.split("/")[1]; + const userId = jobUserMap.get(jobId); + if (userId && heldUserIds.has(userId)) continue; + try { await deletePrefix(dir.key); removed++; @@ -146,20 +262,43 @@ async function storageTtlSweep(): Promise<{ removed: number; failed: number }> { if (removed > 0) { console.log(`Storage TTL: removed ${removed} expired job dirs`); } - return { removed, failed: errors.length }; + return { removed: removed + deleteAfterCleaned, failed: errors.length }; } // -- Retention sweep ---------------------------------------------------------- async function retentionSweep(): Promise { + // Subquery to find users under legal hold (direct or via team) + const heldUsersSubquery = sql`( + SELECT u.id FROM users u + LEFT JOIN teams t ON u.team = t.id + WHERE u.legal_hold = true OR t.legal_hold = true + )`; + if (env.JOBS_RETENTION_DAYS > 0) { await db.execute( - sql`DELETE FROM jobs WHERE created_at < now() - ${env.JOBS_RETENTION_DAYS} * interval '1 day' AND status IN ('completed', 'failed', 'canceled')`, + sql`DELETE FROM jobs + WHERE created_at < now() - ${env.JOBS_RETENTION_DAYS} * interval '1 day' + AND status IN ('completed', 'failed', 'canceled') + AND (user_id IS NULL OR user_id NOT IN ${heldUsersSubquery})`, ); } if (env.AUDIT_RETENTION_DAYS > 0) { - await db.execute( - sql`DELETE FROM audit_log WHERE created_at < now() - ${env.AUDIT_RETENTION_DAYS} * interval '1 day'`, - ); + const tamperResult = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, "tamperResistantAudit")) + .limit(1); + + const isTamperResistant = tamperResult.length > 0 && tamperResult[0].value === "true"; + + // Only delete audit logs if tamper-resistant mode is OFF + if (!isTamperResistant) { + await db.execute( + sql`DELETE FROM audit_log + WHERE created_at < now() - ${env.AUDIT_RETENTION_DAYS} * interval '1 day' + AND (actor_id IS NULL OR actor_id NOT IN ${heldUsersSubquery})`, + ); + } } } diff --git a/apps/api/src/lib/audit-integrity.ts b/apps/api/src/lib/audit-integrity.ts new file mode 100644 index 00000000..204b9ee3 --- /dev/null +++ b/apps/api/src/lib/audit-integrity.ts @@ -0,0 +1,22 @@ +import { createHmac } from "node:crypto"; + +export function canonicalize(obj: unknown): string { + if (obj === null || obj === undefined) return "null"; + if (typeof obj !== "object") return JSON.stringify(obj); + if (Array.isArray(obj)) return `[${obj.map(canonicalize).join(",")}]`; + + const sorted = Object.keys(obj as Record) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonicalize((obj as Record)[k])}`) + .join(","); + return `{${sorted}}`; +} + +export function computeHmac(data: Record, key: Buffer): string { + return createHmac("sha256", key).update(canonicalize(data)).digest("hex"); +} + +export function verifyHmac(data: Record, hmac: string, key: Buffer): boolean { + const computed = computeHmac(data, key); + return computed === hmac; +} diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index f0b492f4..da60f4b7 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -1,80 +1,141 @@ import { randomUUID } from "node:crypto"; -import type { FastifyBaseLogger } from "fastify"; +import { eq } from "drizzle-orm"; +import type { FastifyBaseLogger, FastifyRequest } from "fastify"; +import { env } from "../config.js"; import { db, schema } from "../db/index.js"; +import { computeHmac } from "./audit-integrity.js"; +import { deriveAuditHmacKey } from "./encryption.js"; const MAX_AUDIT_INPUT_LENGTH = 200; +/** + * Check whether tool operation audit logging is enabled. + * + * Two paths can enable it: + * 1. The `auditToolOperations` admin setting is explicitly "true". + * 2. An active enterprise license enables the `audit_export` feature. + * + * Returns false on any error so a broken check never blocks tool execution. + */ +export async function isToolAuditEnabled(): Promise { + try { + const result = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, "auditToolOperations")) + .limit(1); + if (result.length > 0 && result[0].value === "true") return true; + } catch { + // fall through to enterprise check + } + + try { + const enterprise = await import("@snapotter/enterprise"); + return enterprise.isFeatureEnabled("audit_export"); + } catch { + return false; + } +} + export function sanitizeAuditInput(raw: string): string { return raw.replace(/[<>&"']/g, "").slice(0, MAX_AUDIT_INPUT_LENGTH) || "(empty)"; } -type AuditEvent = - | "LOGIN_SUCCESS" - | "LOGIN_FAILED" - | "LOGOUT" - | "PASSWORD_CHANGED" - | "PASSWORD_RESET" - | "USER_CREATED" - | "USER_DELETED" - | "USER_UPDATED" - | "FILE_UPLOADED" - | "FILE_DELETED" - | "API_KEY_CREATED" - | "API_KEY_DELETED" - | "ROLE_CREATED" - | "ROLE_UPDATED" - | "ROLE_DELETED" - | "SETTINGS_UPDATED" - | "OIDC_LOGIN_SUCCESS" - | "OIDC_USER_CREATED" - | "OIDC_USER_LINKED" - | "OIDC_LOGIN_FAILED"; - /** * Emit a structured audit log entry for security-relevant events. * - * Dual-writes: structured stdout log (for aggregators) + SQLite row. + * Dual-writes: structured stdout log (for aggregators) + DB row. */ export async function auditLog( logger: FastifyBaseLogger, - event: AuditEvent, + event: string, details: Record = {}, + ip: string | null = null, + requestId: string | null = null, ): Promise { - logger.info({ audit: true, event, ...details }, `[AUDIT] ${event}`); + logger.info({ audit: true, event, ip, requestId, ...details }, `[AUDIT] ${event}`); const actorId = (details.userId as string) ?? (details.adminId as string) ?? null; const actorUsername = (details.username as string) ?? (details.newUsername as string) ?? "system"; const targetId = (details.targetUserId as string) ?? (details.keyId as string) ?? null; const targetType = deriveTargetType(event); + const id = randomUUID(); try { await db.insert(schema.auditLog).values({ - id: randomUUID(), + id, actorId, actorUsername, action: event, targetType, targetId, details, - ipAddress: null, + ipAddress: ip, + requestId, }); } catch { logger.warn({ event }, "Failed to write audit log to DB"); + return; + } + + // Compute HMAC for tamper-resistant mode + if (env.DATA_ENCRYPTION_KEY) { + try { + const tamperResult = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, "tamperResistantAudit")) + .limit(1); + + if (tamperResult.length > 0 && tamperResult[0].value === "true") { + const hmacKey = await deriveAuditHmacKey(env.DATA_ENCRYPTION_KEY); + const rowData = { + actorId, + actorUsername, + action: event, + targetType, + targetId, + details, + ipAddress: ip, + requestId, + }; + const integrity = computeHmac(rowData, hmacKey); + await db.update(schema.auditLog).set({ integrity }).where(eq(schema.auditLog.id, id)); + } + } catch { + logger.warn({ event }, "Failed to compute audit HMAC"); + } } } -function deriveTargetType(event: AuditEvent): string | null { +/** + * Create a bound audit logger from a Fastify request. + * Captures request.ip and request.id so call sites only need event + details. + */ +export function auditFromRequest(request: FastifyRequest) { + return (event: string, details: Record = {}) => + auditLog(request.log, event, details, request.ip, request.id); +} + +function deriveTargetType(event: string): string | null { if ( event.startsWith("USER_") || event.startsWith("LOGIN") || event.startsWith("PASSWORD") || event.startsWith("OIDC_") || + event.startsWith("SAML_") || + event.startsWith("SCIM_") || + event.startsWith("MFA_") || event === "LOGOUT" ) return "user"; if (event.startsWith("API_KEY")) return "api_key"; if (event.startsWith("FILE")) return "file"; if (event.startsWith("ROLE")) return "role"; - if (event === "SETTINGS_UPDATED") return "setting"; + if (event === "SETTINGS_UPDATED" || event === "IP_ALLOWLIST_UPDATED") return "setting"; + if (event.startsWith("TOOL_") || event.startsWith("BATCH_") || event.startsWith("PIPELINE_")) + return "tool"; + if (event.startsWith("LEGAL_HOLD")) return "compliance"; + if (event.startsWith("SIEM_") || event.startsWith("WEBHOOK_")) return "integration"; return null; } diff --git a/apps/api/src/lib/encryption.ts b/apps/api/src/lib/encryption.ts new file mode 100644 index 00000000..c060c2c5 --- /dev/null +++ b/apps/api/src/lib/encryption.ts @@ -0,0 +1,64 @@ +import { createCipheriv, createDecipheriv, hkdf as hkdfCb, randomBytes } from "node:crypto"; +import { promisify } from "node:util"; + +const hkdf = promisify(hkdfCb); + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const KEY_VERSION = 1; +const PREFIX = "$ENC$"; + +async function deriveKey(masterKeyHex: string, context: string): Promise { + const keyBytes = Buffer.from(masterKeyHex, "hex"); + const derived = await hkdf("sha256", keyBytes, Buffer.alloc(0), context, 32); + return Buffer.from(derived); +} + +export async function encrypt(plaintext: string, masterKeyHex: string): Promise { + const key = await deriveKey(masterKeyHex, "snapotter-settings-encryption"); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + const blob = Buffer.concat([Buffer.from([KEY_VERSION]), iv, authTag, encrypted]); + return `${PREFIX}${blob.toString("base64")}`; +} + +export async function decrypt( + ciphertext: string, + masterKeyHex: string, + previousKeyHex?: string, +): Promise { + if (!isEncrypted(ciphertext)) return ciphertext; + + const blob = Buffer.from(ciphertext.slice(PREFIX.length), "base64"); + const _version = blob[0]; + const iv = blob.subarray(1, 1 + IV_LENGTH); + const authTag = blob.subarray(1 + IV_LENGTH, 1 + IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = blob.subarray(1 + IV_LENGTH + AUTH_TAG_LENGTH); + + const tryDecrypt = async (keyHex: string): Promise => { + try { + const key = await deriveKey(keyHex, "snapotter-settings-encryption"); + const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final("utf8"); + } catch { + return null; + } + }; + + const result = await tryDecrypt(masterKeyHex); + if (result !== null) return result; + if (previousKeyHex) return tryDecrypt(previousKeyHex); + return null; +} + +export function isEncrypted(value: string): boolean { + return value.startsWith(PREFIX); +} + +export async function deriveAuditHmacKey(masterKeyHex: string): Promise { + return deriveKey(masterKeyHex, "snapotter-audit-hmac"); +} diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index afbdd261..1df582e6 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -58,10 +58,7 @@ const envSchema = z LIBREOFFICE_TIMEOUT_S: z.coerce.number().default(120), SESSION_DURATION_HOURS: z.coerce.number().default(168), LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30), - TRUST_PROXY: z - .enum(["true", "false"]) - .default("false") - .transform((v) => v === "true"), + TRUST_PROXY: z.string().default("false"), OIDC_ENABLED: z .enum(["true", "false"]) .default("false") @@ -82,6 +79,26 @@ const envSchema = z OIDC_PROVIDER_NAME: z.string().default(""), OIDC_CLOCK_TOLERANCE: z.coerce.number().min(0).max(300).default(30), OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), + SAML_ENABLED: z + .enum(["true", "false"]) + .default("false") + .transform((v) => v === "true"), + SAML_ENTITY_ID: z.string().default(""), + SAML_CALLBACK_URL: z.string().default(""), + SAML_IDP_SSO_URL: z.string().default(""), + SAML_IDP_CERTIFICATE: z.string().default(""), + SAML_AUTO_CREATE_USERS: z + .enum(["true", "false"]) + .default("true") + .transform((v) => v === "true"), + SAML_AUTO_LINK_USERS: z + .enum(["true", "false"]) + .default("false") + .transform((v) => v === "true"), + SAML_DEFAULT_ROLE: z.string().default("user"), + SAML_PROVIDER_NAME: z.string().default(""), + SAML_USERNAME_ATTRIBUTE: z.string().default(""), + SAML_EMAIL_ATTRIBUTE: z.string().default("email"), EXTERNAL_URL: z.string().default(""), COOKIE_SECRET: z.string().default(""), REDIS_URL: z.string().default("redis://localhost:6379"), @@ -100,6 +117,8 @@ const envSchema = z POSTHOG_API_KEY: z.string().default(""), POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), SENTRY_DSN: z.string().default(""), + DATA_ENCRYPTION_KEY: z.string().default(""), + DATA_ENCRYPTION_KEY_PREVIOUS: z.string().default(""), }) .superRefine((data, ctx) => { if (data.STORAGE_MODE === "s3") { @@ -155,6 +174,47 @@ const envSchema = z }); } } + if (data.SAML_ENABLED) { + if (!data.SAML_IDP_SSO_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "SAML_IDP_SSO_URL is required when SAML_ENABLED=true", + path: ["SAML_IDP_SSO_URL"], + }); + } + if (!data.SAML_IDP_CERTIFICATE) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "SAML_IDP_CERTIFICATE is required when SAML_ENABLED=true", + path: ["SAML_IDP_CERTIFICATE"], + }); + } + if (!data.EXTERNAL_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "EXTERNAL_URL is required when SAML_ENABLED=true", + path: ["EXTERNAL_URL"], + }); + } + } + if (data.DATA_ENCRYPTION_KEY) { + if (!/^[0-9a-fA-F]{64}$/.test(data.DATA_ENCRYPTION_KEY)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["DATA_ENCRYPTION_KEY"], + message: "DATA_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)", + }); + } + } + if (data.DATA_ENCRYPTION_KEY_PREVIOUS) { + if (!/^[0-9a-fA-F]{64}$/.test(data.DATA_ENCRYPTION_KEY_PREVIOUS)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["DATA_ENCRYPTION_KEY_PREVIOUS"], + message: "DATA_ENCRYPTION_KEY_PREVIOUS must be a 64-character hex string (32 bytes)", + }); + } + } }); export type Env = z.infer; diff --git a/apps/api/src/lib/external-auth-resolver.ts b/apps/api/src/lib/external-auth-resolver.ts new file mode 100644 index 00000000..2cd7744a --- /dev/null +++ b/apps/api/src/lib/external-auth-resolver.ts @@ -0,0 +1,214 @@ +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import type { FastifyBaseLogger } from "fastify"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { auditLog, sanitizeAuditInput } from "./audit.js"; + +// ── Types ───────────────────────────────────────────────────────── + +export interface ExternalAuthParams { + provider: string; // "oidc" or "saml" + externalId: string; // OIDC sub or SAML NameID + email?: string; + emailVerified?: boolean; + username: string; // derived/sanitized username + autoCreate: boolean; + autoLink: boolean; + defaultRole: string; + logger: FastifyBaseLogger; + ip: string; + requestId: string; +} + +export interface ExternalAuthResult { + user: { id: string; username: string; role: string; team: string } | null; + action: "matched" | "linked" | "created" | "denied"; + deniedReason?: "user_not_authorized" | "user_limit_reached"; +} + +// ── Username helpers ────────────────────────────────────────────── + +export function sanitizeUsername(raw: string): string { + let sanitized = raw + .toLowerCase() + .replace(/[^a-z0-9_.-]/g, "_") + .replace(/_{2,}/g, "_") + .replace(/^[_.-]+|[_.-]+$/g, ""); + + // Enforce 3-50 char limit (truncate to 46 to leave room for collision suffix) + if (sanitized.length > 46) { + sanitized = sanitized.slice(0, 46); + } + if (sanitized.length < 3) { + sanitized = sanitized.padEnd(3, "_"); + } + + return sanitized; +} + +export async function findUniqueUsername(base: string): Promise { + const [existing] = await db + .select({ username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.username, base)); + + if (!existing) return base; + + for (let i = 2; i <= 1000; i++) { + const candidate = `${base}_${i}`; + const [taken] = await db + .select({ username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.username, candidate)); + if (!taken) return candidate; + } + + // Extremely unlikely fallback + return `${base}_${Date.now()}`; +} + +// ── Resolver ───────────────────────────────────────────────────── + +export async function resolveExternalUser(params: ExternalAuthParams): Promise { + const { + provider, + externalId, + email, + emailVerified, + username, + autoCreate, + autoLink, + defaultRole, + logger, + ip, + requestId, + } = params; + + const providerUpper = provider.toUpperCase(); + + const audit = (event: string, details: Record = {}) => + auditLog(logger, event, details, ip, requestId); + + // 1. Match by externalId + const [existingByExtId] = await db + .select() + .from(schema.users) + .where(eq(schema.users.externalId, externalId)) + .limit(1); + + if (existingByExtId) { + // Update email if changed + if (email && email !== existingByExtId.email) { + await db + .update(schema.users) + .set({ email, updatedAt: new Date() }) + .where(eq(schema.users.id, existingByExtId.id)); + } + return { + user: { + id: existingByExtId.id, + username: existingByExtId.username, + role: existingByExtId.role, + team: existingByExtId.team, + }, + action: "matched", + }; + } + + // 2. Auto-link by email + if (autoLink && email && emailVerified) { + const [existingByEmail] = await db + .select() + .from(schema.users) + .where(eq(schema.users.email, email)) + .limit(1); + + if (existingByEmail) { + await db + .update(schema.users) + .set({ + externalId, + authProvider: provider, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, existingByEmail.id)); + + await audit(`${providerUpper}_USER_LINKED`, { + userId: existingByEmail.id, + username: existingByEmail.username, + email, + }); + + return { + user: { + id: existingByEmail.id, + username: existingByEmail.username, + role: existingByEmail.role, + team: existingByEmail.team, + }, + action: "linked", + }; + } + } + + // 3. Auto-create + if (autoCreate) { + // Check user limit + if (env.MAX_USERS > 0) { + const [countResult] = await db.select({ count: sql`COUNT(*)` }).from(schema.users); + if (countResult && countResult.count >= env.MAX_USERS) { + logger.warn(`${provider} auto-create blocked: user limit reached`); + return { user: null, action: "denied", deniedReason: "user_limit_reached" }; + } + } + + const uniqueUsername = await findUniqueUsername(username); + const newUserId = randomUUID(); + + // Look up the default team + const [defaultTeam] = await db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, "Default")); + const teamId = defaultTeam?.id ?? "default-team-00000000"; + + await db.insert(schema.users).values({ + id: newUserId, + username: uniqueUsername, + passwordHash: null, + role: defaultRole, + team: teamId, + mustChangePassword: false, + authProvider: provider, + externalId, + email: email ?? null, + }); + + await audit(`${providerUpper}_USER_CREATED`, { + userId: newUserId, + username: uniqueUsername, + email, + role: defaultRole, + }); + + return { + user: { + id: newUserId, + username: uniqueUsername, + role: defaultRole, + team: teamId, + }, + action: "created", + }; + } + + // 4. Denied: no matching user, auto-link did not match, auto-create disabled + logger.warn({ externalId, email }, `${provider} user not authorized`); + await audit(`${providerUpper}_LOGIN_FAILED`, { + reason: "user_not_authorized", + externalId: sanitizeAuditInput(String(externalId)), + }); + + return { user: null, action: "denied", deniedReason: "user_not_authorized" }; +} diff --git a/apps/api/src/lib/metrics.ts b/apps/api/src/lib/metrics.ts index 7aeb0974..5e7320c0 100644 --- a/apps/api/src/lib/metrics.ts +++ b/apps/api/src/lib/metrics.ts @@ -5,7 +5,7 @@ * and a metricsText() function that appends live queue-depth gauges * from BullMQ before returning the scrape payload. */ -import { Counter, collectDefaultMetrics, Histogram, Registry } from "prom-client"; +import { Counter, collectDefaultMetrics, Gauge, Histogram, Registry } from "prom-client"; import { perPoolCounts } from "../jobs/queues.js"; export const registry = new Registry(); @@ -26,6 +26,28 @@ export const jobDuration = new Histogram({ registers: [registry], }); +export const requestDuration = new Histogram({ + name: "snapotter_http_request_duration_seconds", + help: "HTTP request duration in seconds", + labelNames: ["route_group", "status_class"] as const, + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [registry], +}); + +export const storageUsage = new Gauge({ + name: "snapotter_storage_bytes", + help: "Storage usage in bytes", + labelNames: ["category"] as const, + registers: [registry], +}); + +export const authAttempts = new Counter({ + name: "snapotter_auth_attempts_total", + help: "Authentication attempts", + labelNames: ["method", "result"] as const, + registers: [registry], +}); + export async function metricsText(): Promise { const counts = await perPoolCounts(); const lines: string[] = [ diff --git a/apps/api/src/lib/settings-helpers.ts b/apps/api/src/lib/settings-helpers.ts new file mode 100644 index 00000000..d1f04dec --- /dev/null +++ b/apps/api/src/lib/settings-helpers.ts @@ -0,0 +1,55 @@ +import { eq } from "drizzle-orm"; +import { db, schema } from "../db/index.js"; + +/** + * Atomically insert or update a setting using Postgres ON CONFLICT DO UPDATE. + * Eliminates the TOCTOU race in the old SELECT-then-INSERT/UPDATE pattern. + */ +export async function upsertSetting(key: string, value: string): Promise { + await db + .insert(schema.settings) + .values({ key, value }) + .onConflictDoUpdate({ + target: schema.settings.key, + set: { value, updatedAt: new Date() }, + }); +} + +/** + * Read a numeric setting from the DB `settings` table. + * Returns `defaultValue` when the key is missing, non-numeric, or on DB error. + */ +export async function getSettingNumber(key: string, defaultValue = 0): Promise { + try { + const result = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, key)) + .limit(1); + if (result.length > 0) { + const num = Number(result[0].value); + if (!Number.isNaN(num)) return num; + } + } catch { + /* DB not ready or key absent -- fall through */ + } + return defaultValue; +} + +/** + * Read a string setting from the DB `settings` table. + * Returns `defaultValue` when the key is missing or on DB error. + */ +export async function getSettingString(key: string, defaultValue = ""): Promise { + try { + const result = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, key)) + .limit(1); + if (result.length > 0) return result[0].value; + } catch { + /* DB not ready or key absent -- fall through */ + } + return defaultValue; +} diff --git a/apps/api/src/lib/webhook-delivery.ts b/apps/api/src/lib/webhook-delivery.ts new file mode 100644 index 00000000..8e850825 --- /dev/null +++ b/apps/api/src/lib/webhook-delivery.ts @@ -0,0 +1,66 @@ +interface DeliveryOptions { + maxRetries?: number; + initialDelayMs?: number; + timeoutMs?: number; +} + +interface DeliveryResult { + success: boolean; + statusCode?: number; + error?: string; + attempts: number; +} + +export async function deliverWebhook( + url: string, + authHeader: string, + events: Record[], + options: DeliveryOptions = {}, +): Promise { + const { maxRetries = 3, initialDelayMs = 1000, timeoutMs = 30_000 } = options; + + const payload = JSON.stringify({ + source: "snapotter", + version: "1", + events, + }); + + const headers: Record = { "Content-Type": "application/json" }; + if (authHeader) headers["Authorization"] = authHeader; + + let lastError: string | undefined; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + const delay = initialDelayMs * 2 ** (attempt - 1); + await new Promise((r) => setTimeout(r, delay)); + } + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: payload, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (response.ok) { + return { success: true, statusCode: response.status, attempts: attempt + 1 }; + } + + lastError = `HTTP ${response.status}`; + // Don't retry 4xx errors (client errors = won't succeed on retry) + if (response.status >= 400 && response.status < 500) { + return { + success: false, + statusCode: response.status, + error: lastError, + attempts: attempt + 1, + }; + } + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + } + + return { success: false, error: lastError, attempts: maxRetries + 1 }; +} diff --git a/apps/api/src/permissions.ts b/apps/api/src/permissions.ts index 84320856..755db47f 100644 --- a/apps/api/src/permissions.ts +++ b/apps/api/src/permissions.ts @@ -20,6 +20,9 @@ const ROLE_PERMISSIONS: Record = { "features:manage", "system:health", "audit:read", + "compliance:manage", + "webhooks:manage", + "security:manage", ], editor: [ "tools:use", @@ -83,6 +86,48 @@ export function requirePermission( }; } +export async function hasToolAccess(role: string, toolId: string): Promise { + // Built-in roles have no tool restrictions + if (role in ROLE_PERMISSIONS) return true; + + try { + const [roleRow] = await db + .select({ toolPermissions: schema.roles.toolPermissions }) + .from(schema.roles) + .where(eq(schema.roles.name, role)) + .limit(1); + + // Role not found or no toolPermissions configured -- allow all + if (!roleRow?.toolPermissions) return true; + + const tp = roleRow.toolPermissions; + + if (tp.mode === "category") { + const { TOOLS } = await import("@snapotter/shared"); + const tool = TOOLS.find((t) => t.id === toolId); + if (!tool) return false; + return tp.allowed.includes(tool.modality ?? tool.category); + } + + if (tp.mode === "tool") { + // Per-tool mode requires enterprise license + let isEnterprise = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + isEnterprise = isFeatureEnabled("per_tool_permissions"); + } catch {} + + if (!isEnterprise) return true; // Graceful degradation -- no enterprise = allow all + return tp.allowed.includes(toolId); + } + + return true; // Unknown mode = allow + } catch { + // DB not yet available during early startup + return true; + } +} + export async function requireOwnershipOrPermission( request: FastifyRequest, reply: FastifyReply, diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 38022e47..8f3548f7 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -1,11 +1,14 @@ import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; -import { and, eq, ne, sql } from "drizzle-orm"; +import { and, asc, eq, ne, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; -import { auditLog, sanitizeAuditInput } from "../lib/audit.js"; +import { sharedRedis } from "../jobs/connection.js"; +import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; +import { authAttempts } from "../lib/metrics.js"; +import { getSettingNumber, getSettingString } from "../lib/settings-helpers.js"; import { getPermissions, requirePermission } from "../permissions.js"; const scryptAsync = promisify(scrypt); @@ -49,14 +52,23 @@ export function computeKeyPrefix(rawKey: string): string { return createHash("sha256").update(rawKey).digest("hex").slice(0, 16); } -const PASSWORD_RULES = - "Password must be at least 8 characters with uppercase, lowercase, and a number"; +async function validatePasswordStrength(password: string): Promise { + const minLength = await getSettingNumber("passwordMinLength", 8); + if (password.length < minLength) return `Password must be at least ${minLength} characters`; + + const requireUpper = await getSettingString("passwordRequireUppercase", "true"); + const requireLower = await getSettingString("passwordRequireLowercase", "true"); + const requireDigit = await getSettingString("passwordRequireDigit", "true"); + const requireSpecial = await getSettingString("passwordRequireSpecial", "false"); + + if (requireUpper === "true" && !/[A-Z]/.test(password)) + return "Password must contain an uppercase letter"; + if (requireLower === "true" && !/[a-z]/.test(password)) + return "Password must contain a lowercase letter"; + if (requireDigit === "true" && !/\d/.test(password)) return "Password must contain a digit"; + if (requireSpecial === "true" && !/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) + return "Password must contain a special character"; -function validatePasswordStrength(password: string): string | null { - if (password.length < 8) return PASSWORD_RULES; - if (!/[A-Z]/.test(password)) return PASSWORD_RULES; - if (!/[a-z]/.test(password)) return PASSWORD_RULES; - if (!/[0-9]/.test(password)) return PASSWORD_RULES; return null; } @@ -211,6 +223,9 @@ export async function ensureBuiltinRoles(): Promise { "features:manage", "system:health", "audit:read", + "compliance:manage", + "webhooks:manage", + "security:manage", ], isBuiltin: true, }, @@ -269,6 +284,28 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.status(403).send({ error: "Authentication is disabled" }); } + // SSO enforcement check + const ssoEnforced = await getSettingString("ssoEnforcement", "false"); + if (ssoEnforced === "true") { + let isEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + isEnabled = isFeatureEnabled("sso_enforcement"); + } catch {} + + if (isEnabled) { + const breakGlassUsername = await getSettingString("ssoBreakGlassUsername", ""); + const { username } = loginSchema.parse(request.body); + + if (username !== breakGlassUsername) { + return reply.status(403).send({ + error: "Local password login is disabled. Please use SSO.", + code: "SSO_ENFORCED", + }); + } + } + } + const parsed = loginSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: "Username and password are required" }); @@ -287,8 +324,11 @@ export async function authRoutes(app: FastifyInstance): Promise { .from(schema.users) .where(eq(schema.users.username, body.username)); + const audit = auditFromRequest(request); + if (!user || !user.passwordHash) { - await auditLog(request.log, "LOGIN_FAILED", { + authAttempts.inc({ method: "password", result: "failure" }); + await audit("LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "unknown_user", }); @@ -297,13 +337,40 @@ export async function authRoutes(app: FastifyInstance): Promise { const valid = await verifyPassword(body.password, user.passwordHash); if (!valid) { - await auditLog(request.log, "LOGIN_FAILED", { + authAttempts.inc({ method: "password", result: "failure" }); + await audit("LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "bad_password", }); return reply.status(401).send({ error: "Invalid credentials" }); } + // ── MFA challenge ────────────────────────────────────────── + if (user.totpEnabled) { + const mfaToken = randomUUID(); + const redis = sharedRedis(); + await redis.setex(`mfa:${mfaToken}`, 300, user.id); + + await audit("MFA_CHALLENGE_ISSUED", { userId: user.id, username: user.username }); + + // Determine if MFA policy requires enrollment for this user + let mfaRequired = false; + try { + const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js"); + const policy = await getMfaPolicy(); + mfaRequired = isMfaRequiredForUser(policy, user.role); + } catch { + // MFA plugin not loaded + } + + return reply.status(200).send({ + requiresMfa: true, + mfaToken, + mfaRequired, + message: "MFA verification required", + }); + } + // Create session const token = createSessionToken(); const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); @@ -314,10 +381,38 @@ export async function authRoutes(app: FastifyInstance): Promise { expiresAt, }); - await auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username }); + // ── Concurrent session limit (FIFO eviction) ────────────── + const maxSessions = await getSettingNumber("maxSessionsPerUser"); + if (maxSessions > 0) { + const sessions = await db + .select({ id: schema.sessions.id, createdAt: schema.sessions.createdAt }) + .from(schema.sessions) + .where(eq(schema.sessions.userId, user.id)) + .orderBy(asc(schema.sessions.createdAt)); + + if (sessions.length > maxSessions) { + const toDelete = sessions.slice(0, sessions.length - maxSessions); + for (const s of toDelete) { + await db.delete(schema.sessions).where(eq(schema.sessions.id, s.id)); + } + } + } + + authAttempts.inc({ method: "password", result: "success" }); + await audit("LOGIN_SUCCESS", { userId: user.id, username: user.username }); const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, user.team)); + // Check if MFA enrollment is required by policy but user hasn't enrolled yet + let mfaRequired = false; + try { + const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js"); + const policy = await getMfaPolicy(); + mfaRequired = isMfaRequiredForUser(policy, user.role) && !user.totpEnabled; + } catch { + // MFA plugin not loaded + } + return reply.send({ token, user: { @@ -332,6 +427,7 @@ export async function authRoutes(app: FastifyInstance): Promise { analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null, }, expiresAt: expiresAt.toISOString(), + ...(mfaRequired && { mfaRequired: true }), }); }, ); @@ -375,7 +471,7 @@ export async function authRoutes(app: FastifyInstance): Promise { cookieReply.clearCookie("snapotter-session", { path: "/" }); } - await auditLog(request.log, "LOGOUT", { userId: user?.id }); + await auditFromRequest(request)("LOGOUT", { userId: user?.id }); return reply.send({ ok: true, ...(logoutUrl && { logoutUrl }) }); }); @@ -425,7 +521,7 @@ export async function authRoutes(app: FastifyInstance): Promise { mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : user.mustChangePassword, permissions: await getPermissions(user.role), authProvider: user.authProvider ?? "local", - loginMethod: session.idToken ? "oidc" : "local", + loginMethod: session.idToken ? "oidc" : user.authProvider === "saml" ? "saml" : "local", email: user.email ?? null, hasLocalPassword: !!user.passwordHash, hasOidcLink: !!user.externalId, @@ -451,7 +547,7 @@ export async function authRoutes(app: FastifyInstance): Promise { } const body = parsed.data; - const pwError = validatePasswordStrength(body.newPassword); + const pwError = await validatePasswordStrength(body.newPassword); if (pwError) { return reply.status(400).send({ error: pwError, @@ -497,7 +593,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // Revoke all API keys - if credentials were compromised, keys must be rotated too await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, authUser.id)); - await auditLog(request.log, "PASSWORD_CHANGED", { + await auditFromRequest(request)("PASSWORD_CHANGED", { userId: authUser.id, username: authUser.username, }); @@ -566,7 +662,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }); } - const registerPwError = validatePasswordStrength(body.password); + const registerPwError = await validatePasswordStrength(body.password); if (registerPwError) { return reply.status(400).send({ error: registerPwError, @@ -666,7 +762,7 @@ export async function authRoutes(app: FastifyInstance): Promise { mustChangePassword: true, }); - await auditLog(request.log, "USER_CREATED", { + await auditFromRequest(request)("USER_CREATED", { adminId: admin.id, newUserId: id, newUsername: body.username, @@ -781,7 +877,7 @@ export async function authRoutes(app: FastifyInstance): Promise { ); } - await auditLog(request.log, "USER_UPDATED", { + await auditFromRequest(request)("USER_UPDATED", { adminId: admin.id, targetUserId: id, changes: { role: updates.role, team: updates.team }, @@ -808,7 +904,7 @@ export async function authRoutes(app: FastifyInstance): Promise { } const body = parsed.data; - const pwError = validatePasswordStrength(body.newPassword); + const pwError = await validatePasswordStrength(body.newPassword); if (pwError) { return reply.status(400).send({ error: pwError, @@ -842,7 +938,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // Revoke all API keys await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, id)); - await auditLog(request.log, "PASSWORD_RESET", { + await auditFromRequest(request)("PASSWORD_RESET", { adminId: admin.id, targetUserId: id, targetUsername: user.username, @@ -880,7 +976,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // Delete the user (cascades to api_keys via FK) await db.delete(schema.users).where(eq(schema.users.id, id)); - await auditLog(request.log, "USER_DELETED", { + await auditFromRequest(request)("USER_DELETED", { adminId: admin.id, deletedUserId: id, deletedUsername: user.username, @@ -917,6 +1013,7 @@ const PUBLIC_PATHS = [ "/api/docs", "/api/v1/openapi.yaml", "/api/v1/meme-templates/", + "/api/v1/scim/", ]; function isPublicRoute(url: string): boolean { @@ -998,6 +1095,7 @@ export async function authMiddleware(app: FastifyInstance): Promise { .from(schema.users) .where(eq(schema.users.id, key.userId)); if (apiUser) { + authAttempts.inc({ method: "apikey", result: "success" }); const keyPermissions = key.permissions ?? undefined; (request as FastifyRequest & { user?: AuthUser }).user = { id: apiUser.id, @@ -1009,6 +1107,7 @@ export async function authMiddleware(app: FastifyInstance): Promise { } } } + authAttempts.inc({ method: "apikey", result: "failure" }); } // Public routes can proceed without a valid session @@ -1023,6 +1122,36 @@ export async function authMiddleware(app: FastifyInstance): Promise { return reply.status(401).send({ error: "User not found" }); } + // ── Idle timeout enforcement ─────────────────────────────────── + const idleTimeoutMinutes = await getSettingNumber("sessionIdleTimeoutMinutes"); + if (idleTimeoutMinutes > 0) { + const redis = sharedRedis(); + const idleKey = `session:idle:${token}`; + const lastSeen = await redis.get(idleKey); + + if (!lastSeen) { + // Redis key expired or first request -- check Postgres lastActivity + if (session.lastActivity) { + const elapsed = Date.now() - session.lastActivity.getTime(); + if (elapsed > idleTimeoutMinutes * 60 * 1000) { + await db.delete(schema.sessions).where(eq(schema.sessions.id, token)); + if (isPublic) return; + return reply + .status(401) + .send({ error: "Session expired due to inactivity", code: "IDLE_TIMEOUT" }); + } + } + // Flush lastActivity to Postgres on cache miss (avoids per-request DB writes) + await db + .update(schema.sessions) + .set({ lastActivity: new Date() }) + .where(eq(schema.sessions.id, token)); + } + + // Refresh Redis key with TTL = idle timeout + await redis.setex(idleKey, idleTimeoutMinutes * 60, Date.now().toString()); + } + // Attach user info to request for downstream handlers // (always populate when a valid session exists, even on public routes) (request as FastifyRequest & { user?: AuthUser }).user = { diff --git a/apps/api/src/plugins/ip-allowlist.ts b/apps/api/src/plugins/ip-allowlist.ts new file mode 100644 index 00000000..93d10c90 --- /dev/null +++ b/apps/api/src/plugins/ip-allowlist.ts @@ -0,0 +1,178 @@ +/** + * Enterprise IP allowlist plugin. + * + * Registers an onRequest hook that checks whether the request IP falls + * within any allowed CIDR range. Uses Node 22's built-in BlockList for + * zero-dependency CIDR matching. The allowlist is cached in-process and + * synchronized across instances via Redis pub/sub. + * + * Only active when the enterprise `ip_allowlist` feature is licensed. + */ +import { BlockList } from "node:net"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + +// Paths exempt from IP filtering -- infrastructure probes, IdP callbacks +const EXEMPT_PATHS = [ + "/api/v1/health", + "/api/v1/readyz", + "/api/v1/metrics", + "/api/v1/scim/", // SCIM from cloud IdPs + "/api/auth/saml/callback", // SAML assertion POST + "/api/auth/oidc/callback", // OIDC redirect +]; + +function isExemptPath(url: string): boolean { + return EXEMPT_PATHS.some((p) => url.startsWith(p)); +} + +/** + * Parse an array of CIDR strings (or bare addresses) into a BlockList. + * Returns null when the list is empty (meaning "allow all"). + */ +export function buildBlockList(cidrs: string[]): BlockList | null { + if (cidrs.length === 0) return null; + const bl = new BlockList(); + for (const cidr of cidrs) { + try { + if (cidr.includes("/")) { + const [addr, prefix] = cidr.split("/"); + bl.addSubnet(addr, Number(prefix), addr.includes(":") ? "ipv6" : "ipv4"); + } else { + bl.addAddress(cidr, cidr.includes(":") ? "ipv6" : "ipv4"); + } + } catch { + // Invalid CIDR -- skip silently + } + } + return bl; +} + +/** + * Validate a single CIDR string (or bare IP). + * Returns true when the string can be parsed without error. + */ +export function isValidCidr(cidr: string): boolean { + try { + const bl = new BlockList(); + if (cidr.includes("/")) { + const [addr, prefix] = cidr.split("/"); + const prefixNum = Number(prefix); + if (Number.isNaN(prefixNum) || prefixNum < 0) return false; + const family = addr.includes(":") ? "ipv6" : "ipv4"; + if (family === "ipv4" && prefixNum > 32) return false; + if (family === "ipv6" && prefixNum > 128) return false; + bl.addSubnet(addr, prefixNum, family); + } else { + bl.addAddress(cidr, cidr.includes(":") ? "ipv6" : "ipv4"); + } + return true; + } catch { + return false; + } +} + +/** + * Check whether an IP is covered by a BlockList (used as an allowlist). + * Handles IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) by extracting the + * inner IPv4 address and checking both forms. + */ +export function isIpAllowed(ip: string, bl: BlockList): boolean { + const family = ip.includes(":") ? "ipv6" : "ipv4"; + if (bl.check(ip, family)) return true; + + // IPv4-mapped IPv6 -- also check the bare IPv4 portion + if (ip.startsWith("::ffff:")) { + const v4 = ip.slice(7); + if (bl.check(v4, "ipv4")) return true; + } + + return false; +} + +// Re-export for tests +export { EXEMPT_PATHS, isExemptPath }; + +const ALLOWLIST_KEY = "ip:allowlist"; +const ALLOWLIST_CHANNEL = "ip:allowlist:refresh"; + +export async function registerIpAllowlist(app: FastifyInstance): Promise { + // Only run if enterprise feature is enabled + let isEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + isEnabled = isFeatureEnabled("ip_allowlist"); + } catch { + // Enterprise package not available + } + if (!isEnabled) return; + + // Load allowlist from settings table + async function loadAllowlist(): Promise { + const { getSettingString } = await import("../lib/settings-helpers.js"); + const raw = await getSettingString("ipAllowlist", ""); + if (!raw) return []; + try { + return JSON.parse(raw) as string[]; + } catch { + return []; + } + } + + // Cache in-process + const { sharedRedis } = await import("../jobs/connection.js"); + const redis = sharedRedis(); + + let cachedBlockList: BlockList | null = null; + let cachedCidrs: string[] = []; + + async function refreshAllowlist(): Promise { + const cidrs = await loadAllowlist(); + cachedCidrs = cidrs; + cachedBlockList = buildBlockList(cidrs); + // Mirror into Redis so other instances can bootstrap faster + await redis.set(ALLOWLIST_KEY, JSON.stringify(cidrs)); + } + + // Initial load + await refreshAllowlist(); + + // Subscribe to refresh events from other instances + const sub = redis.duplicate(); + await sub.subscribe(ALLOWLIST_CHANNEL); + sub.on("message", async () => { + refreshAllowlist().catch(() => {}); + }); + + // Clean up subscriber on shutdown + app.addHook("onClose", async () => { + try { + await sub.unsubscribe(); + await sub.quit(); + } catch { + // Best-effort cleanup + } + }); + + // Hook -- runs before auth, before routes + app.addHook("onRequest", async (request: FastifyRequest, reply: FastifyReply) => { + if (!cachedBlockList || cachedCidrs.length === 0) return; // No allowlist = allow all + if (isExemptPath(request.url)) return; + + const ip = request.ip; + if (!isIpAllowed(ip, cachedBlockList)) { + return reply.status(403).send({ error: "IP address not allowed" }); + } + }); + + app.log.info(`IP allowlist active (${cachedCidrs.length} entries)`); +} + +/** + * Notify all instances to reload the IP allowlist from the DB. + * Called by the admin API after updating the setting. + */ +export async function publishAllowlistRefresh(): Promise { + const { sharedRedis } = await import("../jobs/connection.js"); + const redis = sharedRedis(); + await redis.publish(ALLOWLIST_CHANNEL, "refresh"); +} diff --git a/apps/api/src/plugins/mfa.ts b/apps/api/src/plugins/mfa.ts new file mode 100644 index 00000000..94fa27d8 --- /dev/null +++ b/apps/api/src/plugins/mfa.ts @@ -0,0 +1,448 @@ +import { createHash, randomBytes } from "node:crypto"; +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import * as OTPAuth from "otpauth"; +import { z } from "zod"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { sharedRedis } from "../jobs/connection.js"; +import { auditFromRequest } from "../lib/audit.js"; +import { decrypt, encrypt } from "../lib/encryption.js"; +import { getSettingString } from "../lib/settings-helpers.js"; +import { getPermissions } from "../permissions.js"; +import { createSessionToken, getAuthUser, requireAuth } from "./auth.js"; + +// ── Constants ───────────────────────────────────────────────────── + +const RECOVERY_CODE_COUNT = 8; +const SESSION_DURATION_MS = env.SESSION_DURATION_HOURS * 60 * 60 * 1000; + +// ── Zod schemas ─────────────────────────────────────────────────── + +const verifyCodeSchema = z.object({ + code: z.string().min(1, "Code is required").max(20, "Code too long"), +}); + +const completeSchema = z.object({ + mfaToken: z.string().uuid("Invalid MFA token"), + code: z.string().min(1, "Code is required").max(20, "Code too long"), +}); + +const disableSchema = z.object({ + code: z.string().min(1, "Code is required").max(20, "Code too long"), +}); + +// ── Recovery code helpers ───────────────────────────────────────── + +export function hashRecoveryCodes(codes: string[]): string { + return codes.map((c) => createHash("sha256").update(c).digest("hex")).join(","); +} + +export function verifyRecoveryCode( + code: string, + hashList: string, +): { valid: boolean; remaining: string } { + const hashes = hashList.split(","); + const codeHash = createHash("sha256").update(code).digest("hex"); + const idx = hashes.indexOf(codeHash); + if (idx === -1) return { valid: false, remaining: hashList }; + hashes.splice(idx, 1); + return { valid: true, remaining: hashes.join(",") }; +} + +function generateRecoveryCodes(): string[] { + return Array.from({ length: RECOVERY_CODE_COUNT }, () => randomBytes(4).toString("hex")); +} + +// ── TOTP helpers ────────────────────────────────────────────────── + +export function createTotp(username: string, secretBase32?: string): OTPAuth.TOTP { + return new OTPAuth.TOTP({ + issuer: "SnapOtter", + label: username, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: secretBase32 + ? OTPAuth.Secret.fromBase32(secretBase32) + : new OTPAuth.Secret({ size: 20 }), + }); +} + +export function verifyTotpCode(secretBase32: string, code: string): boolean { + const totp = createTotp("verify", secretBase32); + // Allow 1-step window in either direction for clock drift + const delta = totp.validate({ token: code, window: 1 }); + return delta !== null; +} + +async function encryptSecret(secretBase32: string): Promise { + if (env.DATA_ENCRYPTION_KEY) { + return encrypt(secretBase32, env.DATA_ENCRYPTION_KEY); + } + return secretBase32; +} + +async function decryptSecret(stored: string): Promise { + if (env.DATA_ENCRYPTION_KEY) { + return decrypt(stored, env.DATA_ENCRYPTION_KEY, env.DATA_ENCRYPTION_KEY_PREVIOUS || undefined); + } + return stored; +} + +// ── MFA policy helpers ──────────────────────────────────────────── + +export type MfaPolicy = "optional" | "admins_only" | "required"; + +export async function getMfaPolicy(): Promise { + const raw = await getSettingString("mfaPolicy", "optional"); + if (raw === "required" || raw === "admins_only") return raw; + return "optional"; +} + +export function isMfaRequiredForUser(policy: MfaPolicy, userRole: string): boolean { + if (policy === "required") return true; + if (policy === "admins_only" && userRole === "admin") return true; + return false; +} + +// ── MFA plugin registration ─────────────────────────────────────── + +export async function registerMfa(app: FastifyInstance): Promise { + // POST /api/auth/mfa/enroll -- start MFA enrollment + app.post("/api/auth/mfa/enroll", async (request: FastifyRequest, reply: FastifyReply) => { + const user = requireAuth(request, reply); + if (!user) return; + + // Check enterprise feature gate + let mfaLicensed = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + mfaLicensed = isFeatureEnabled("mfa"); + } catch { + // Enterprise package not available + } + + if (!mfaLicensed) { + return reply.status(403).send({ + error: "MFA requires an enterprise license", + code: "FEATURE_NOT_LICENSED", + }); + } + + // Check if already enrolled + const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id)); + if (!dbUser) { + return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); + } + if (dbUser.totpEnabled) { + return reply.status(409).send({ + error: "MFA is already enabled. Disable it first to re-enroll.", + code: "MFA_ALREADY_ENABLED", + }); + } + + // Check if there's already a pending (unverified) enrollment + if (dbUser.totpSecret && !dbUser.totpEnabled) { + return reply.status(409).send({ + error: + "MFA enrollment already pending. Complete verification first or contact an admin to reset.", + code: "MFA_ENROLLMENT_PENDING", + }); + } + + // Generate TOTP secret + const totp = createTotp(user.username); + const uri = totp.toString(); + + // Generate recovery codes + const recoveryCodes = generateRecoveryCodes(); + const recoveryHash = hashRecoveryCodes(recoveryCodes); + + // Encrypt TOTP secret for storage + const encryptedSecret = await encryptSecret(totp.secret.base32); + + // Store pending enrollment (not yet active) + await db + .update(schema.users) + .set({ + totpSecret: encryptedSecret, + totpEnabled: false, + recoveryCodesHash: recoveryHash, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, user.id)); + + return reply.send({ uri, recoveryCodes }); + }); + + // POST /api/auth/mfa/verify -- confirm enrollment with a TOTP code + app.post("/api/auth/mfa/verify", async (request: FastifyRequest, reply: FastifyReply) => { + const user = requireAuth(request, reply); + if (!user) return; + + const parsed = verifyCodeSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "A valid TOTP code is required", + code: "VALIDATION_ERROR", + }); + } + const { code } = parsed.data; + + const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id)); + if (!dbUser?.totpSecret) { + return reply.status(400).send({ + error: "No pending MFA enrollment found. Call /api/auth/mfa/enroll first.", + code: "NO_PENDING_ENROLLMENT", + }); + } + if (dbUser.totpEnabled) { + return reply.status(409).send({ + error: "MFA is already verified and active", + code: "MFA_ALREADY_ENABLED", + }); + } + + // Decrypt the stored secret + const secretBase32 = await decryptSecret(dbUser.totpSecret); + if (!secretBase32) { + return reply.status(500).send({ + error: "Failed to decrypt TOTP secret", + code: "DECRYPTION_FAILED", + }); + } + + // Validate the code + if (!verifyTotpCode(secretBase32, code)) { + return reply.status(401).send({ + error: "Invalid TOTP code", + code: "INVALID_CODE", + }); + } + + // Activate MFA + await db + .update(schema.users) + .set({ totpEnabled: true, updatedAt: new Date() }) + .where(eq(schema.users.id, user.id)); + + const audit = auditFromRequest(request); + await audit("MFA_ENROLLED", { userId: user.id, username: user.username }); + + return reply.send({ ok: true }); + }); + + // POST /api/auth/mfa/complete -- complete login with TOTP code + app.post("/api/auth/mfa/complete", async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = completeSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "MFA token and code are required", + code: "VALIDATION_ERROR", + }); + } + const { mfaToken, code } = parsed.data; + + // Look up the pending MFA challenge in Redis + const redis = sharedRedis(); + const userId = await redis.get(`mfa:${mfaToken}`); + if (!userId) { + return reply.status(401).send({ + error: "MFA challenge expired or invalid", + code: "MFA_EXPIRED", + }); + } + + // Load user + const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); + if (!dbUser?.totpSecret) { + return reply.status(401).send({ + error: "User not found or MFA not configured", + code: "MFA_NOT_CONFIGURED", + }); + } + + // Decrypt the stored secret + const secretBase32 = await decryptSecret(dbUser.totpSecret); + if (!secretBase32) { + return reply.status(500).send({ + error: "Failed to decrypt TOTP secret", + code: "DECRYPTION_FAILED", + }); + } + + const audit = auditFromRequest(request); + let verified = false; + let recoveryUsed = false; + + // Try TOTP code first + if (verifyTotpCode(secretBase32, code)) { + verified = true; + } + + // Try recovery code if TOTP failed + if (!verified && dbUser.recoveryCodesHash) { + const result = verifyRecoveryCode(code, dbUser.recoveryCodesHash); + if (result.valid) { + verified = true; + recoveryUsed = true; + // Consume the recovery code + await db + .update(schema.users) + .set({ recoveryCodesHash: result.remaining || null, updatedAt: new Date() }) + .where(eq(schema.users.id, userId)); + } + } + + if (!verified) { + await audit("MFA_VERIFY_FAILED", { userId, username: dbUser.username }); + return reply.status(401).send({ + error: "Invalid TOTP or recovery code", + code: "INVALID_CODE", + }); + } + + // Delete the challenge token + await redis.del(`mfa:${mfaToken}`); + + // Create session (same as normal login completion) + const token = createSessionToken(); + const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); + + await db.insert(schema.sessions).values({ + id: token, + userId: dbUser.id, + expiresAt, + }); + + await audit(recoveryUsed ? "MFA_RECOVERY_USED" : "MFA_VERIFIED", { + userId: dbUser.id, + username: dbUser.username, + }); + + const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, dbUser.team)); + + return reply.send({ + token, + user: { + id: dbUser.id, + username: dbUser.username, + role: dbUser.role, + mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : dbUser.mustChangePassword, + permissions: await getPermissions(dbUser.role), + teamName: teamRow?.name ?? dbUser.team, + analyticsEnabled: dbUser.analyticsEnabled ?? null, + analyticsConsentShownAt: dbUser.analyticsConsentShownAt?.getTime() ?? null, + analyticsConsentRemindAt: dbUser.analyticsConsentRemindAt?.getTime() ?? null, + }, + expiresAt: expiresAt.toISOString(), + }); + }); + + // POST /api/auth/mfa/disable -- disable MFA (self-service) + app.post("/api/auth/mfa/disable", async (request: FastifyRequest, reply: FastifyReply) => { + const user = requireAuth(request, reply); + if (!user) return; + + const parsed = disableSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Current TOTP code is required to disable MFA", + code: "VALIDATION_ERROR", + }); + } + const { code } = parsed.data; + + const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id)); + if (!dbUser?.totpEnabled || !dbUser.totpSecret) { + return reply.status(400).send({ + error: "MFA is not enabled", + code: "MFA_NOT_ENABLED", + }); + } + + // Decrypt and verify the code + const secretBase32 = await decryptSecret(dbUser.totpSecret); + if (!secretBase32) { + return reply.status(500).send({ + error: "Failed to decrypt TOTP secret", + code: "DECRYPTION_FAILED", + }); + } + + if (!verifyTotpCode(secretBase32, code)) { + return reply.status(401).send({ + error: "Invalid TOTP code", + code: "INVALID_CODE", + }); + } + + // Clear MFA data + await db + .update(schema.users) + .set({ + totpSecret: null, + totpEnabled: false, + recoveryCodesHash: null, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, user.id)); + + const audit = auditFromRequest(request); + await audit("MFA_DISABLED", { userId: user.id, username: user.username }); + + return reply.send({ ok: true }); + }); + + // POST /api/auth/users/:id/mfa/reset -- admin reset + app.post( + "/api/auth/users/:id/mfa/reset", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + const admin = getAuthUser(request); + if (!admin) { + return reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" }); + } + + // Check users:manage permission + const { hasEffectivePermission } = await import("../permissions.js"); + if (!(await hasEffectivePermission(admin, "users:manage"))) { + return reply.status(403).send({ error: "Insufficient permissions", code: "FORBIDDEN" }); + } + + const { id } = request.params; + + const [targetUser] = await db.select().from(schema.users).where(eq(schema.users.id, id)); + if (!targetUser) { + return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); + } + + if (!targetUser.totpEnabled) { + return reply.status(400).send({ + error: "MFA is not enabled for this user", + code: "MFA_NOT_ENABLED", + }); + } + + // Clear MFA data + await db + .update(schema.users) + .set({ + totpSecret: null, + totpEnabled: false, + recoveryCodesHash: null, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, id)); + + const audit = auditFromRequest(request); + await audit("MFA_RESET", { + adminId: admin.id, + targetUserId: id, + targetUsername: targetUser.username, + }); + + return reply.send({ ok: true }); + }, + ); +} diff --git a/apps/api/src/plugins/oidc.ts b/apps/api/src/plugins/oidc.ts index 464c925e..fdc1dc78 100644 --- a/apps/api/src/plugins/oidc.ts +++ b/apps/api/src/plugins/oidc.ts @@ -1,11 +1,11 @@ -import { randomUUID } from "node:crypto"; import type {} from "@fastify/cookie"; -import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import * as oidc from "openid-client"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; -import { auditLog, sanitizeAuditInput } from "../lib/audit.js"; +import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; +import { resolveExternalUser, sanitizeUsername } from "../lib/external-auth-resolver.js"; +import { authAttempts } from "../lib/metrics.js"; import { createSessionToken } from "./auth.js"; // ── Types ───────────────────────────────────────────────────────── @@ -85,45 +85,6 @@ function deriveUsername(claims: Record): string { return claims.sub as string; } -function sanitizeUsername(raw: string): string { - let sanitized = raw - .toLowerCase() - .replace(/[^a-z0-9_.-]/g, "_") - .replace(/_{2,}/g, "_") - .replace(/^[_.-]+|[_.-]+$/g, ""); - - // Enforce 3-50 char limit (truncate to 46 to leave room for collision suffix) - if (sanitized.length > 46) { - sanitized = sanitized.slice(0, 46); - } - if (sanitized.length < 3) { - sanitized = sanitized.padEnd(3, "_"); - } - - return sanitized; -} - -async function findUniqueUsername(base: string): Promise { - const [existing] = await db - .select({ username: schema.users.username }) - .from(schema.users) - .where(eq(schema.users.username, base)); - - if (!existing) return base; - - for (let i = 2; i <= 1000; i++) { - const candidate = `${base}_${i}`; - const [taken] = await db - .select({ username: schema.users.username }) - .from(schema.users) - .where(eq(schema.users.username, candidate)); - if (!taken) return candidate; - } - - // Extremely unlikely fallback - return `${base}_${Date.now()}`; -} - // ── Helpers ─────────────────────────────────────────────────────── function isSecure(): boolean { @@ -221,13 +182,16 @@ export async function oidcRoutes(app: FastifyInstance): Promise { return redirectToLogin(reply, "oidc_session_expired"); } + const audit = auditFromRequest(request); + // Check for error response from the IdP if (query.error) { request.log.warn( { error: query.error, description: query.error_description }, "OIDC IdP returned error", ); - await auditLog(request.log, "OIDC_LOGIN_FAILED", { + authAttempts.inc({ method: "oidc", result: "failure" }); + await audit("OIDC_LOGIN_FAILED", { reason: sanitizeAuditInput(String(query.error)), }); return redirectToLogin(reply, "oidc_auth_failed"); @@ -257,7 +221,8 @@ export async function oidcRoutes(app: FastifyInstance): Promise { }); } catch (err) { request.log.error({ err }, "OIDC token exchange failed"); - await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" }); + authAttempts.inc({ method: "oidc", result: "failure" }); + await audit("OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" }); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -265,7 +230,8 @@ export async function oidcRoutes(app: FastifyInstance): Promise { const claims = tokenResponse.claims(); if (!claims) { request.log.error("OIDC callback: no ID token claims"); - await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "no_id_token" }); + authAttempts.inc({ method: "oidc", result: "failure" }); + await audit("OIDC_LOGIN_FAILED", { reason: "no_id_token" }); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -273,124 +239,49 @@ export async function oidcRoutes(app: FastifyInstance): Promise { const email = typeof claims.email === "string" ? claims.email : undefined; const emailVerified = claims.email_verified === true; const rawUsername = deriveUsername(claims as Record); - const username = sanitizeUsername(rawUsername); + const derivedUsername = sanitizeUsername(rawUsername); const idToken = tokenResponse.id_token ?? null; - // 4. User resolution - let userId: string | null = null; + // 4. User resolution (delegated to shared resolver) + const result = await resolveExternalUser({ + provider: "oidc", + externalId: sub, + email, + emailVerified, + username: derivedUsername, + autoCreate: env.OIDC_AUTO_CREATE_USERS, + autoLink: env.OIDC_AUTO_LINK_USERS, + defaultRole: env.OIDC_DEFAULT_ROLE, + logger: request.log, + ip: request.ip, + requestId: request.id, + }); - // 4a. Find by externalId (OIDC subject) - const [existingByExtId] = await db - .select() - .from(schema.users) - .where(eq(schema.users.externalId, sub)) - .limit(1); - - if (existingByExtId) { - userId = existingByExtId.id; - // Update email if changed - if (email && email !== existingByExtId.email) { - await db - .update(schema.users) - .set({ email, updatedAt: new Date() }) - .where(eq(schema.users.id, existingByExtId.id)); + if (result.action === "denied" || !result.user) { + authAttempts.inc({ method: "oidc", result: "failure" }); + if (result.deniedReason === "user_limit_reached") { + return redirectToLogin(reply, "oidc_user_limit_reached"); } - } - - // 4b. Auto-link: match by email - if (!userId && env.OIDC_AUTO_LINK_USERS && email && emailVerified) { - const [existingByEmail] = await db - .select() - .from(schema.users) - .where(eq(schema.users.email, email)) - .limit(1); - - if (existingByEmail) { - await db - .update(schema.users) - .set({ - externalId: sub, - updatedAt: new Date(), - }) - .where(eq(schema.users.id, existingByEmail.id)); - userId = existingByEmail.id; - await auditLog(request.log, "OIDC_USER_LINKED", { - userId: existingByEmail.id, - username: existingByEmail.username, - email, - }); - } - } - - // 4c. Auto-create - if (!userId && env.OIDC_AUTO_CREATE_USERS) { - // Check user limit - if (env.MAX_USERS > 0) { - const [countResult] = await db.select({ count: sql`COUNT(*)` }).from(schema.users); - if (countResult && countResult.count >= env.MAX_USERS) { - request.log.warn("OIDC auto-create blocked: user limit reached"); - return redirectToLogin(reply, "oidc_user_limit_reached"); - } - } - - const uniqueUsername = await findUniqueUsername(username); - const newUserId = randomUUID(); - - // Look up the default team - const [defaultTeam] = await db - .select() - .from(schema.teams) - .where(eq(schema.teams.name, "Default")); - const teamId = defaultTeam?.id ?? "default-team-00000000"; - - await db.insert(schema.users).values({ - id: newUserId, - username: uniqueUsername, - passwordHash: null, - role: env.OIDC_DEFAULT_ROLE, - team: teamId, - mustChangePassword: false, - authProvider: "oidc", - externalId: sub, - email: email ?? null, - }); - - userId = newUserId; - await auditLog(request.log, "OIDC_USER_CREATED", { - userId: newUserId, - username: uniqueUsername, - email, - role: env.OIDC_DEFAULT_ROLE, - }); - } - - // 4d. No user found and no auto-create - if (!userId) { - request.log.warn({ sub, email }, "OIDC user not authorized"); - await auditLog(request.log, "OIDC_LOGIN_FAILED", { - reason: "user_not_authorized", - sub: sanitizeAuditInput(String(sub)), - }); return redirectToLogin(reply, "oidc_user_not_authorized"); } + const resolvedUser = result.user; + // 5. Create session const token = createSessionToken(); const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); await db.insert(schema.sessions).values({ id: token, - userId, + userId: resolvedUser.id, expiresAt, idToken, }); - // Fetch the user for audit logging - const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); - - await auditLog(request.log, "OIDC_LOGIN_SUCCESS", { - userId, - username: user?.username ?? username, + authAttempts.inc({ method: "oidc", result: "success" }); + await audit("OIDC_LOGIN_SUCCESS", { + userId: resolvedUser.id, + username: resolvedUser.username, }); // 6. Set session cookie diff --git a/apps/api/src/plugins/per-user-rate-limit.ts b/apps/api/src/plugins/per-user-rate-limit.ts new file mode 100644 index 00000000..f940e3a9 --- /dev/null +++ b/apps/api/src/plugins/per-user-rate-limit.ts @@ -0,0 +1,57 @@ +/** + * Per-user rate limiting using Redis sliding window (sorted sets). + * + * Runs AFTER auth middleware so `request.user` is populated. + * Only applies to authenticated users on /api/ routes. + * The limit is controlled by the `rateLimitPerUser` DB setting (0 = unlimited). + */ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { sharedRedis } from "../jobs/connection.js"; +import { getSettingNumber } from "../lib/settings-helpers.js"; +import { getAuthUser } from "./auth.js"; + +const WINDOW_MS = 60_000; // 1-minute sliding window + +export async function registerPerUserRateLimit(app: FastifyInstance): Promise { + app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => { + const user = getAuthUser(request); + if (!user) return; // Anonymous/public requests skip per-user limits + + // Only rate-limit API routes + if (!request.url.startsWith("/api/")) return; + + const rateLimitPerUser = await getSettingNumber("rateLimitPerUser", 0); + if (rateLimitPerUser <= 0) return; // 0 = unlimited + + const redis = sharedRedis(); + const key = `ratelimit:user:${user.id}`; + const now = Date.now(); + + // Sliding window using Redis sorted set: + // 1. Remove entries older than the window + // 2. Add current request with timestamp as score + // 3. Count entries in the window + // 4. Set TTL slightly longer than window for cleanup + const multi = redis.multi(); + multi.zremrangebyscore(key, 0, now - WINDOW_MS); + multi.zadd(key, now, `${now}:${Math.random()}`); + multi.zcard(key); + multi.expire(key, 61); + const results = await multi.exec(); + + // multi.exec() returns [[err, result], ...] for each command + const requestCount = (results?.[2]?.[1] as number) ?? 0; + + // Set standard rate limit headers + reply.header("X-RateLimit-Limit", rateLimitPerUser); + reply.header("X-RateLimit-Remaining", Math.max(0, rateLimitPerUser - requestCount)); + reply.header("X-RateLimit-Reset", Math.ceil((now + WINDOW_MS) / 1000)); + + if (requestCount > rateLimitPerUser) { + return reply.status(429).send({ + error: "Rate limit exceeded", + retryAfter: Math.ceil(WINDOW_MS / 1000), + }); + } + }); +} diff --git a/apps/api/src/plugins/saml.ts b/apps/api/src/plugins/saml.ts new file mode 100644 index 00000000..e68ce3a8 --- /dev/null +++ b/apps/api/src/plugins/saml.ts @@ -0,0 +1,183 @@ +import { parse as parseQs } from "node:querystring"; +import type {} from "@fastify/cookie"; +import { SAML } from "@node-saml/node-saml"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { auditFromRequest } from "../lib/audit.js"; +import { + findUniqueUsername, + resolveExternalUser, + sanitizeUsername, +} from "../lib/external-auth-resolver.js"; +import { authAttempts } from "../lib/metrics.js"; +import { createSessionToken } from "./auth.js"; + +// -- SAML instance factory ---------------------------------------------------- + +function getSamlInstance(): SAML { + return new SAML({ + callbackUrl: env.SAML_CALLBACK_URL || `${env.EXTERNAL_URL}/api/auth/saml/callback`, + entryPoint: env.SAML_IDP_SSO_URL, + issuer: env.SAML_ENTITY_ID || `${env.EXTERNAL_URL}/api/auth/saml/metadata`, + idpCert: env.SAML_IDP_CERTIFICATE, + wantAuthnResponseSigned: true, + wantAssertionsSigned: true, + }); +} + +// -- Helpers ------------------------------------------------------------------ + +function isSecure(): boolean { + return env.EXTERNAL_URL.startsWith("https"); +} + +const SESSION_DURATION_MS = env.SESSION_DURATION_HOURS * 60 * 60 * 1000; + +function redirectToLogin(reply: FastifyReply, errorCode: string): void { + reply.redirect(`/login?error=${errorCode}`); +} + +// -- Plugin registration ------------------------------------------------------ + +export async function registerSaml(app: FastifyInstance): Promise { + if (!env.SAML_ENABLED) return; + + let isEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + isEnabled = isFeatureEnabled("saml_sso"); + } catch { + // Enterprise package not available + } + + if (!isEnabled) { + app.log.warn("SAML is enabled via env but saml_sso enterprise feature is not licensed"); + return; + } + + // Register form-urlencoded content type parser for the SAML callback. + // The IdP POSTs the SAML response as application/x-www-form-urlencoded. + app.addContentTypeParser( + "application/x-www-form-urlencoded", + { parseAs: "string" }, + (_request, body, done) => { + try { + const str = typeof body === "string" ? body : (body as Buffer).toString(); + done(null, parseQs(str)); + } catch (err) { + done(err as Error, undefined); + } + }, + ); + + // GET /api/auth/saml/metadata -- SP metadata XML + app.get("/api/auth/saml/metadata", async (_request: FastifyRequest, reply: FastifyReply) => { + const saml = getSamlInstance(); + const metadata = saml.generateServiceProviderMetadata(null, null); + return reply.type("application/xml").send(metadata); + }); + + // GET /api/auth/saml/login -- SP-initiated login redirect + app.get("/api/auth/saml/login", async (_request: FastifyRequest, reply: FastifyReply) => { + try { + const saml = getSamlInstance(); + const loginUrl = await saml.getAuthorizeUrlAsync("", undefined, {}); + return reply.redirect(loginUrl); + } catch (err) { + _request.log.error({ err }, "SAML login redirect failed"); + return redirectToLogin(reply, "saml_auth_failed"); + } + }); + + // POST /api/auth/saml/callback -- Assertion Consumer Service (ACS) + app.post("/api/auth/saml/callback", async (request: FastifyRequest, reply: FastifyReply) => { + const saml = getSamlInstance(); + const audit = auditFromRequest(request); + + let profile; + try { + const result = await saml.validatePostResponseAsync(request.body as Record); + profile = result.profile; + } catch (err) { + request.log.error({ err }, "SAML assertion validation failed"); + authAttempts.inc({ method: "saml", result: "failure" }); + await audit("SAML_LOGIN_FAILED", { + error: err instanceof Error ? err.message : "Unknown error", + }); + return redirectToLogin(reply, "saml_auth_failed"); + } + + if (!profile || !profile.nameID) { + request.log.warn("SAML callback: no profile or nameID in assertion"); + authAttempts.inc({ method: "saml", result: "failure" }); + await audit("SAML_LOGIN_FAILED", { reason: "missing_profile" }); + return redirectToLogin(reply, "saml_auth_failed"); + } + + // Extract claims from SAML assertion + const externalId = profile.nameID; + const email = profile[env.SAML_EMAIL_ATTRIBUTE] as string | undefined; + const usernameAttr = env.SAML_USERNAME_ATTRIBUTE + ? (profile[env.SAML_USERNAME_ATTRIBUTE] as string | undefined) + : undefined; + + // Derive a username from available claims + const rawUsername = usernameAttr || email?.split("@")[0] || profile.nameID; + let username = sanitizeUsername(rawUsername); + username = await findUniqueUsername(username); + + // Resolve user via shared external-auth resolver + const result = await resolveExternalUser({ + provider: "saml", + externalId, + email, + emailVerified: true, // SAML assertions from a trusted IdP are considered verified + username, + autoCreate: env.SAML_AUTO_CREATE_USERS, + autoLink: env.SAML_AUTO_LINK_USERS, + defaultRole: env.SAML_DEFAULT_ROLE, + logger: request.log, + ip: request.ip, + requestId: request.id, + }); + + if (result.action === "denied" || !result.user) { + authAttempts.inc({ method: "saml", result: "failure" }); + const errorParam = + result.deniedReason === "user_limit_reached" + ? "saml_user_limit_reached" + : "saml_user_not_authorized"; + return redirectToLogin(reply, errorParam); + } + + const resolvedUser = result.user; + + // Create session (same pattern as OIDC) + const token = createSessionToken(); + const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); + + await db.insert(schema.sessions).values({ + id: token, + userId: resolvedUser.id, + expiresAt, + }); + + authAttempts.inc({ method: "saml", result: "success" }); + await audit("SAML_LOGIN_SUCCESS", { + userId: resolvedUser.id, + username: resolvedUser.username, + }); + + // Set session cookie and redirect to app + reply.setCookie("snapotter-session", token, { + httpOnly: true, + sameSite: "strict", + secure: isSecure(), + path: "/", + maxAge: env.SESSION_DURATION_HOURS * 3600, + }); + + return reply.redirect("/"); + }); +} diff --git a/apps/api/src/routes/admin-ops.ts b/apps/api/src/routes/admin-ops.ts index b209c904..d6ee2d8c 100644 --- a/apps/api/src/routes/admin-ops.ts +++ b/apps/api/src/routes/admin-ops.ts @@ -1,17 +1,19 @@ /** * Admin operations routes -- runtime log level, Prometheus metrics, - * diagnostic support bundle, and usage dashboard. + * diagnostic support bundle, usage dashboard, and backup status tracking. * - * GET /api/v1/admin/log-level -- read current pino log level - * POST /api/v1/admin/log-level -- change level at runtime - * GET /api/v1/metrics -- Prometheus scrape endpoint - * GET /api/v1/admin/support-bundle -- download redacted diagnostic zip - * GET /api/v1/admin/usage -- local usage dashboard data + * GET /api/v1/admin/log-level -- read current pino log level + * POST /api/v1/admin/log-level -- change level at runtime + * GET /api/v1/metrics -- Prometheus scrape endpoint + * GET /api/v1/admin/support-bundle -- download redacted diagnostic zip + * GET /api/v1/admin/usage -- local usage dashboard data + * POST /api/v1/admin/backup-status -- record backup completion + * GET /api/v1/admin/backup-status -- read last backup info + staleness */ -import { sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; -import { db } from "../db/index.js"; +import { db, schema } from "../db/index.js"; import { formatZodErrors } from "../lib/errors.js"; import { metricsText } from "../lib/metrics.js"; import { buildSupportBundle } from "../lib/support-bundle.js"; @@ -129,6 +131,16 @@ export async function adminOpsRoutes(app: FastifyInstance): Promise { sql`SELECT coalesce(sum(size), 0)::text AS bytes, count(*)::int AS files FROM user_files`, ); + // Per-team storage breakdown from pre-computed user counters + const teamStorageRows = await db + .select({ + teamName: schema.users.team, + totalBytes: sql`coalesce(sum(${schema.users.storageUsed}), 0)::text`, + userCount: sql`count(*)::int`, + }) + .from(schema.users) + .groupBy(schema.users.team); + const jobsPerDay = (jobsPerDayResult.rows as Array>).map((r) => ({ day: String(r.day), total: Number(r.total), @@ -162,6 +174,12 @@ export async function adminOpsRoutes(app: FastifyInstance): Promise { libraryFiles: Number(storageRow.files), }; + const teamStorage = teamStorageRows.map((r) => ({ + teamName: r.teamName, + totalBytes: r.totalBytes, + userCount: r.userCount, + })); + return { days, jobsPerDay, @@ -169,6 +187,96 @@ export async function adminOpsRoutes(app: FastifyInstance): Promise { perUser, durations, storage, + teamStorage, }; }); + + // --------------------------------------------------------------------------- + // Backup status tracking + // --------------------------------------------------------------------------- + + const backupStatusSchema = z.object({ + type: z.string().min(1), + sizeBytes: z.number().optional(), + notes: z.string().optional(), + }); + + const BACKUP_KEY = "backup_last_completed"; + + // POST /api/v1/admin/backup-status -- record backup completion + app.post("/api/v1/admin/backup-status", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = await requirePermission("system:health")(request, reply); + if (!admin) return; + + const parsed = backupStatusSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Invalid backup status", + code: "VALIDATION_ERROR", + details: formatZodErrors(parsed.error.issues), + }); + } + + const timestamp = new Date().toISOString(); + const payload = JSON.stringify({ + timestamp, + type: parsed.data.type, + sizeBytes: parsed.data.sizeBytes ?? null, + notes: parsed.data.notes ?? "", + }); + + const [existing] = await db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, BACKUP_KEY)); + + if (existing) { + await db + .update(schema.settings) + .set({ value: payload, updatedAt: new Date() }) + .where(eq(schema.settings.key, BACKUP_KEY)); + } else { + await db.insert(schema.settings).values({ key: BACKUP_KEY, value: payload }); + } + + return { success: true, timestamp }; + }); + + // GET /api/v1/admin/backup-status -- read last backup info + staleness + app.get("/api/v1/admin/backup-status", async (request: FastifyRequest, reply: FastifyReply) => { + const admin = await requirePermission("system:health")(request, reply); + if (!admin) return; + + const result = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, BACKUP_KEY)) + .limit(1); + + if (result.length === 0) { + return { lastBackup: null, status: "critical", ageHours: null }; + } + + let lastBackup: Record; + try { + lastBackup = JSON.parse(result[0].value); + } catch { + return { lastBackup: null, status: "critical", ageHours: null }; + } + + const ts = new Date(lastBackup.timestamp as string); + const ageMs = Date.now() - ts.getTime(); + const ageHours = Math.round((ageMs / 3_600_000) * 10) / 10; + + let status: "ok" | "warning" | "critical"; + if (ageHours < 24) { + status = "ok"; + } else if (ageHours < 48) { + status = "warning"; + } else { + status = "critical"; + } + + return { lastBackup, status, ageHours }; + }); } diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts index 87016e0e..1cc43947 100644 --- a/apps/api/src/routes/api-keys.ts +++ b/apps/api/src/routes/api-keys.ts @@ -10,7 +10,7 @@ import { and, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db, schema } from "../db/index.js"; -import { auditLog } from "../lib/audit.js"; +import { auditFromRequest } from "../lib/audit.js"; import { getPermissions, hasEffectivePermission } from "../permissions.js"; import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js"; @@ -86,7 +86,11 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise { return reply.status(409).send({ error: "Failed to create API key" }); } - await auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name }); + await auditFromRequest(request)("API_KEY_CREATED", { + userId: user.id, + keyId: id, + keyName: name, + }); // Return the raw key ONCE — it cannot be retrieved again return reply.status(201).send({ @@ -155,7 +159,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise { await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)); - await auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id }); + await auditFromRequest(request)("API_KEY_DELETED", { userId: user.id, keyId: id }); return reply.send({ ok: true }); }, diff --git a/apps/api/src/routes/audit-log.ts b/apps/api/src/routes/audit-log.ts index b5a75d03..c2d0613c 100644 --- a/apps/api/src/routes/audit-log.ts +++ b/apps/api/src/routes/audit-log.ts @@ -12,6 +12,7 @@ export async function auditLogRoutes(app: FastifyInstance): Promise { page?: string; limit?: string; action?: string; + ip?: string; from?: string; to?: string; }; @@ -30,6 +31,9 @@ export async function auditLogRoutes(app: FastifyInstance): Promise { if (request.query.action) { conditions.push(eq(schema.auditLog.action, request.query.action)); } + if (request.query.ip) { + conditions.push(eq(schema.auditLog.ipAddress, request.query.ip)); + } if (request.query.from) { const fromDate = new Date(request.query.from); if (!Number.isNaN(fromDate.getTime())) { @@ -68,6 +72,7 @@ export async function auditLogRoutes(app: FastifyInstance): Promise { targetId: e.targetId, details: e.details ?? null, ipAddress: e.ipAddress, + requestId: e.requestId ?? null, createdAt: e.createdAt.toISOString(), })), total: countResult?.count ?? 0, diff --git a/apps/api/src/routes/enterprise/audit-export.ts b/apps/api/src/routes/enterprise/audit-export.ts new file mode 100644 index 00000000..09c47c7e --- /dev/null +++ b/apps/api/src/routes/enterprise/audit-export.ts @@ -0,0 +1,148 @@ +import { and, desc, eq, gte, lte } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { db, schema } from "../../db/index.js"; +import { requirePermission } from "../../permissions.js"; + +const querySchema = z.object({ + format: z.enum(["csv", "json"]).default("json"), + from: z.string().datetime({ offset: true }).optional(), + to: z.string().datetime({ offset: true }).optional(), + action: z.string().optional(), + actorId: z.string().optional(), + targetType: z.string().optional(), + targetId: z.string().optional(), +}); + +function escapeCsvField(value: string): string { + if (value.includes(",") || value.includes('"') || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export async function registerAuditExport(app: FastifyInstance): Promise { + app.get( + "/api/v1/enterprise/audit/export", + async ( + request: FastifyRequest<{ + Querystring: { + format?: string; + from?: string; + to?: string; + action?: string; + actorId?: string; + targetType?: string; + targetId?: string; + }; + }>, + reply: FastifyReply, + ) => { + const user = await requirePermission("audit:read")(request, reply); + if (!user) return; + + // Check enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("audit_export"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "Audit export requires an enterprise license with the audit_export feature", + }); + } + + const parsed = querySchema.safeParse(request.query); + if (!parsed.success) { + return reply + .status(400) + .send({ error: "Invalid query parameters", details: parsed.error.issues }); + } + const { format, from, to, action, actorId, targetType, targetId } = parsed.data; + + // Build filter conditions + const conditions = []; + if (from) { + conditions.push(gte(schema.auditLog.createdAt, new Date(from))); + } + if (to) { + conditions.push(lte(schema.auditLog.createdAt, new Date(to))); + } + if (action) { + conditions.push(eq(schema.auditLog.action, action)); + } + if (actorId) { + conditions.push(eq(schema.auditLog.actorId, actorId)); + } + if (targetType) { + conditions.push(eq(schema.auditLog.targetType, targetType)); + } + if (targetId) { + conditions.push(eq(schema.auditLog.targetId, targetId)); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const EXPORT_LIMIT = 100_000; + const entries = await db + .select() + .from(schema.auditLog) + .where(where) + .orderBy(desc(schema.auditLog.createdAt)) + .limit(EXPORT_LIMIT); + + if (entries.length === EXPORT_LIMIT) { + reply.header("X-Truncated", "true"); + } + + const rows = entries.map((e) => ({ + id: e.id, + actorId: e.actorId ?? "", + actorUsername: e.actorUsername, + action: e.action, + targetType: e.targetType ?? "", + targetId: e.targetId ?? "", + details: e.details ? JSON.stringify(e.details) : "", + ipAddress: e.ipAddress ?? "", + requestId: e.requestId ?? "", + createdAt: e.createdAt.toISOString(), + })); + + if (format === "csv") { + const headers = [ + "id", + "actorId", + "actorUsername", + "action", + "targetType", + "targetId", + "details", + "ipAddress", + "requestId", + "createdAt", + ]; + const csvLines = [headers.join(",")]; + for (const row of rows) { + csvLines.push( + headers.map((h) => escapeCsvField(String(row[h as keyof typeof row]))).join(","), + ); + } + return reply + .header("Content-Type", "text/csv") + .header("Content-Disposition", 'attachment; filename="audit-export.csv"') + .send(csvLines.join("\n")); + } + + // JSON format + return reply + .header("Content-Type", "application/json") + .header("Content-Disposition", 'attachment; filename="audit-export.json"') + .send(JSON.stringify(rows)); + }, + ); + + app.log.info("Enterprise audit export route registered"); +} diff --git a/apps/api/src/routes/enterprise/config.ts b/apps/api/src/routes/enterprise/config.ts new file mode 100644 index 00000000..e76b4ecf --- /dev/null +++ b/apps/api/src/routes/enterprise/config.ts @@ -0,0 +1,360 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { db, schema } from "../../db/index.js"; +import { auditFromRequest } from "../../lib/audit.js"; +import { requirePermission } from "../../permissions.js"; + +const CONFIG_SCHEMA_VERSION = 1; + +const REDACTED_KEYS = new Set([ + "cookie_secret", + "instance_id", + "siem_config", + "scim_token_hash", + "oidc_client_secret", + "saml_idp_certificate", + "siem_last_forwarded_at", + "siem_consecutive_failures", + "audit_archival_state", + "backup_last_completed", + "webhook_destinations", +]); + +const importSchema = z.object({ + dryRun: z.boolean().default(false), + config: z.object({ + configSchemaVersion: z.number(), + settings: z.record(z.string()).optional(), + roles: z + .array( + z.object({ + name: z.string(), + description: z.string().optional(), + permissions: z.array(z.string()), + toolPermissions: z.any().optional(), + }), + ) + .optional(), + teams: z + .array( + z.object({ + name: z.string(), + storageQuota: z.number().nullable().optional(), + retentionHours: z.number().nullable().optional(), + }), + ) + .optional(), + }), +}); + +export async function registerConfigRoutes(app: FastifyInstance): Promise { + // GET /api/v1/enterprise/config/export + app.get( + "/api/v1/enterprise/config/export", + async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("system:health")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("config_export_import"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: + "Configuration export requires an enterprise license with the config_export_import feature", + }); + } + + const config: Record = { + configSchemaVersion: CONFIG_SCHEMA_VERSION, + appVersion: process.env.APP_VERSION || "unknown", + exportedAt: new Date().toISOString(), + settings: {} as Record, + roles: [] as Array>, + teams: [] as Array>, + }; + + // Read all settings, redact sensitive keys + const allSettings = await db.select().from(schema.settings); + const settingsMap = config.settings as Record; + for (const s of allSettings) { + if (!REDACTED_KEYS.has(s.key)) { + settingsMap[s.key] = s.value; + } + } + + // Export custom roles (not built-in) + const customRoles = await db + .select() + .from(schema.roles) + .where(eq(schema.roles.isBuiltin, false)); + config.roles = customRoles.map((r) => ({ + name: r.name, + description: r.description, + permissions: r.permissions, + toolPermissions: r.toolPermissions, + })); + + // Export teams + const allTeams = await db.select().from(schema.teams); + config.teams = allTeams.map((t) => ({ + name: t.name, + storageQuota: t.storageQuota, + retentionHours: t.retentionHours, + })); + + await auditFromRequest(request)("CONFIG_EXPORTED", { + adminId: user.id, + username: user.username, + }); + + reply.header("content-type", "application/json"); + reply.header("content-disposition", 'attachment; filename="snapotter-config.json"'); + return config; + }, + ); + + // POST /api/v1/enterprise/config/import + app.post( + "/api/v1/enterprise/config/import", + async (request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) => { + const user = await requirePermission("system:health")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("config_export_import"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: + "Configuration import requires an enterprise license with the config_export_import feature", + }); + } + + const parsed = importSchema.safeParse(request.body); + if (!parsed.success) { + return reply + .status(400) + .send({ error: "Invalid import payload", details: parsed.error.issues }); + } + + const { dryRun, config } = parsed.data; + + // Reject future schema versions + if (config.configSchemaVersion > CONFIG_SCHEMA_VERSION) { + return reply.status(400).send({ + error: `Unsupported config schema version ${config.configSchemaVersion} (current: ${CONFIG_SCHEMA_VERSION})`, + }); + } + + // Dependency validation + const validationErrors: string[] = []; + + if (config.settings) { + // SSO enforcement requires OIDC or SAML to be configured + if (config.settings.ssoEnforcement === "true") { + const hasOidc = config.settings.oidcIssuer || config.settings.oidcClientId; + const hasSaml = config.settings.samlIdpUrl || config.settings.samlEntityId; + if (!hasOidc && !hasSaml) { + validationErrors.push( + "ssoEnforcement is enabled but no OIDC or SAML provider is configured in the import", + ); + } + } + + // IP allowlist must have at least one CIDR + if (config.settings.ipAllowlist) { + try { + const cidrs = JSON.parse(config.settings.ipAllowlist); + if (!Array.isArray(cidrs) || cidrs.length === 0) { + validationErrors.push("ipAllowlist is set but contains no CIDR entries"); + } + } catch { + validationErrors.push("ipAllowlist contains invalid JSON"); + } + } + } + + if (validationErrors.length > 0) { + return reply.status(400).send({ + error: "Dependency validation failed", + validationErrors, + }); + } + + // Build a change summary + const changes = { + settings: 0, + roles: 0, + teams: 0, + }; + + // Dry-run: describe what would change + const settingsToUpdate: Array<{ key: string; action: string }> = []; + const rolesToUpsert: Array<{ name: string; action: string }> = []; + const teamsToUpsert: Array<{ name: string; action: string }> = []; + + if (config.settings) { + const existingSettings = await db.select().from(schema.settings); + const existingKeys = new Set(existingSettings.map((s) => s.key)); + + for (const key of Object.keys(config.settings)) { + // Skip redacted keys in import as well + if (REDACTED_KEYS.has(key)) continue; + settingsToUpdate.push({ + key, + action: existingKeys.has(key) ? "update" : "create", + }); + } + changes.settings = settingsToUpdate.length; + } + + if (config.roles) { + const existingRoles = await db + .select() + .from(schema.roles) + .where(eq(schema.roles.isBuiltin, false)); + const existingRoleNames = new Set(existingRoles.map((r) => r.name)); + + for (const role of config.roles) { + rolesToUpsert.push({ + name: role.name, + action: existingRoleNames.has(role.name) ? "update" : "create", + }); + } + changes.roles = rolesToUpsert.length; + } + + if (config.teams) { + const existingTeams = await db.select().from(schema.teams); + const existingTeamNames = new Set(existingTeams.map((t) => t.name)); + + for (const team of config.teams) { + teamsToUpsert.push({ + name: team.name, + action: existingTeamNames.has(team.name) ? "update" : "create", + }); + } + changes.teams = teamsToUpsert.length; + } + + if (dryRun) { + return reply.send({ + dryRun: true, + changes, + details: { + settings: settingsToUpdate, + roles: rolesToUpsert, + teams: teamsToUpsert, + }, + }); + } + + // Apply changes + const now = new Date(); + + // Upsert settings + if (config.settings) { + const existingSettings = await db.select().from(schema.settings); + const existingKeys = new Set(existingSettings.map((s) => s.key)); + + for (const [key, value] of Object.entries(config.settings)) { + if (REDACTED_KEYS.has(key)) continue; + + if (existingKeys.has(key)) { + await db + .update(schema.settings) + .set({ value, updatedAt: now }) + .where(eq(schema.settings.key, key)); + } else { + await db.insert(schema.settings).values({ key, value }); + } + } + } + + // Upsert custom roles + if (config.roles) { + const existingRoles = await db + .select() + .from(schema.roles) + .where(eq(schema.roles.isBuiltin, false)); + const existingRoleMap = new Map(existingRoles.map((r) => [r.name, r])); + + for (const role of config.roles) { + const existing = existingRoleMap.get(role.name); + if (existing) { + await db + .update(schema.roles) + .set({ + description: role.description ?? "", + permissions: role.permissions, + toolPermissions: role.toolPermissions ?? null, + updatedAt: now, + }) + .where(eq(schema.roles.id, existing.id)); + } else { + await db.insert(schema.roles).values({ + id: randomUUID(), + name: role.name, + description: role.description ?? "", + permissions: role.permissions, + toolPermissions: role.toolPermissions ?? null, + isBuiltin: false, + createdAt: now, + updatedAt: now, + }); + } + } + } + + // Upsert teams + if (config.teams) { + const existingTeams = await db.select().from(schema.teams); + const existingTeamMap = new Map(existingTeams.map((t) => [t.name, t])); + + for (const team of config.teams) { + const existing = existingTeamMap.get(team.name); + if (existing) { + await db + .update(schema.teams) + .set({ + storageQuota: team.storageQuota ?? null, + retentionHours: team.retentionHours ?? null, + }) + .where(eq(schema.teams.id, existing.id)); + } else { + await db.insert(schema.teams).values({ + id: randomUUID(), + name: team.name, + storageQuota: team.storageQuota ?? null, + retentionHours: team.retentionHours ?? null, + }); + } + } + } + + await auditFromRequest(request)("CONFIG_IMPORTED", { + adminId: user.id, + username: user.username, + dryRun: false, + changes, + }); + + return reply.send({ applied: true, changes }); + }, + ); + + app.log.info("Enterprise config export/import routes registered"); +} diff --git a/apps/api/src/routes/enterprise/gdpr.ts b/apps/api/src/routes/enterprise/gdpr.ts new file mode 100644 index 00000000..e4675e28 --- /dev/null +++ b/apps/api/src/routes/enterprise/gdpr.ts @@ -0,0 +1,379 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { db, schema } from "../../db/index.js"; +import { requestCancel } from "../../jobs/cancel.js"; +import { getQueue } from "../../jobs/queues.js"; +import { SYSTEM_JOBS } from "../../jobs/system-jobs.js"; +import { auditFromRequest } from "../../lib/audit.js"; +import { deleteStoredFile, deleteThumbnail } from "../../lib/file-storage.js"; +import { deletePrefix } from "../../lib/object-storage.js"; +import { requirePermission } from "../../permissions.js"; + +const purgeBodySchema = z.object({ + confirm: z.literal(true), +}); + +/** + * Purge all data for a single user: files, jobs, audit redaction, sessions, keys, prefs, user row. + * Extracted so it can be reused by the team purge endpoint. + */ +async function purgeUserData(userId: string): Promise { + // a. Delete user's library files from storage + const userFileRows = await db + .select({ storedName: schema.userFiles.storedName }) + .from(schema.userFiles) + .where(eq(schema.userFiles.userId, userId)); + + for (const file of userFileRows) { + await deleteStoredFile(file.storedName); + await deleteThumbnail(file.storedName); + } + + // b. Delete userFiles rows + await db.delete(schema.userFiles).where(eq(schema.userFiles.userId, userId)); + + // c. Delete processing artifacts: workspace objects for each job + const jobRows = await db + .select({ id: schema.jobs.id }) + .from(schema.jobs) + .where(eq(schema.jobs.userId, userId)); + + for (const job of jobRows) { + try { + await deletePrefix(`uploads/${job.id}/`); + } catch { + // Workspace directory may not exist + } + try { + await deletePrefix(`outputs/${job.id}/`); + } catch { + // Workspace directory may not exist + } + } + + // d. Cancel any active BullMQ jobs before deleting DB rows + const activeJobs = await db + .select({ id: schema.jobs.id }) + .from(schema.jobs) + .where( + and(eq(schema.jobs.userId, userId), inArray(schema.jobs.status, ["queued", "processing"])), + ); + + if (activeJobs.length > 0) { + for (const job of activeJobs) { + try { + await requestCancel(job.id); + } catch { + // Best-effort cancellation + } + } + // Wait briefly for cancellation to propagate + await new Promise((r) => setTimeout(r, 500)); + } + + // e. Delete jobs rows + await db.delete(schema.jobs).where(eq(schema.jobs.userId, userId)); + + // f. Redact audit log entries (preserve structure, remove PII) + await db + .update(schema.auditLog) + .set({ actorUsername: "[redacted]", ipAddress: null, details: {} }) + .where(eq(schema.auditLog.actorId, userId)); + + // g. Delete sessions + await db.delete(schema.sessions).where(eq(schema.sessions.userId, userId)); + + // h. Delete apiKeys + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, userId)); + + // i. Delete userPreferences + await db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId)); + + // j. Delete user row (also cascades pipelines) + await db.delete(schema.users).where(eq(schema.users.id, userId)); +} + +export async function registerGdprRoutes(app: FastifyInstance): Promise { + // POST /api/v1/enterprise/users/:id/export -- initiate GDPR data export + app.post( + "/api/v1/enterprise/users/:id/export", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + const user = await requirePermission("compliance:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("gdpr_lifecycle"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "GDPR data export requires an enterprise license with the gdpr_lifecycle feature", + }); + } + + const targetUserId = request.params.id; + + // Validate the target user exists + const [targetUser] = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.id, targetUserId)); + if (!targetUser) { + return reply.status(404).send({ error: "User not found" }); + } + + // Create a durable job row + const jobId = randomUUID(); + await db.insert(schema.jobs).values({ + id: jobId, + userId: targetUserId, + toolId: "gdpr-export", + pool: "system", + type: "system", + status: "queued", + }); + + // Enqueue the system job + const q = getQueue("system"); + await q.add(SYSTEM_JOBS.gdprExport, { userId: targetUserId, jobId } as never, { jobId }); + + await auditFromRequest(request)("GDPR_EXPORT_INITIATED", { + adminId: user.id, + username: user.username, + targetUserId, + jobId, + }); + + return reply.status(202).send({ jobId, message: "Export started" }); + }, + ); + + // GET /api/v1/enterprise/users/:id/export/:jobId -- check export status + app.get( + "/api/v1/enterprise/users/:id/export/:jobId", + async ( + request: FastifyRequest<{ Params: { id: string; jobId: string } }>, + reply: FastifyReply, + ) => { + const user = await requirePermission("compliance:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("gdpr_lifecycle"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "GDPR data export requires an enterprise license with the gdpr_lifecycle feature", + }); + } + + const { jobId } = request.params; + + const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + + if (!job) { + return reply.status(404).send({ error: "Export job not found" }); + } + + if (job.status === "completed") { + const outputRef = job.outputRefs?.[0]; + const filename = outputRef?.split("/").pop() ?? "gdpr-export.zip"; + return reply.send({ + status: "completed", + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`, + }); + } + + if (job.status === "failed") { + const errorMsg = (job.error as { message?: string } | null)?.message ?? "Export failed"; + return reply.send({ status: "failed", error: errorMsg }); + } + + // queued or processing + const progress = job.progress as { percent?: number; stage?: string } | null; + return reply.send({ + status: job.status, + progress: progress?.percent ?? 0, + }); + }, + ); + + // DELETE /api/v1/enterprise/users/:id/purge -- GDPR right-to-erasure + app.delete( + "/api/v1/enterprise/users/:id/purge", + async ( + request: FastifyRequest<{ Params: { id: string }; Body: unknown }>, + reply: FastifyReply, + ) => { + const user = await requirePermission("compliance:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("gdpr_lifecycle"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "GDPR data purge requires an enterprise license with the gdpr_lifecycle feature", + }); + } + + // Validate confirmation body + const parsed = purgeBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Purge requires explicit confirmation", + details: parsed.error.issues, + }); + } + + const targetUserId = request.params.id; + + // Guard: admin cannot purge themselves + if (targetUserId === user.id) { + return reply.status(400).send({ error: "Cannot purge your own account" }); + } + + // Check target user exists + const [targetUser] = await db + .select({ + id: schema.users.id, + team: schema.users.team, + legalHold: schema.users.legalHold, + }) + .from(schema.users) + .where(eq(schema.users.id, targetUserId)); + if (!targetUser) { + return reply.status(404).send({ error: "User not found" }); + } + + // Check legal hold on user + if (targetUser.legalHold) { + return reply.status(409).send({ error: "User or team is under legal hold" }); + } + + // Check legal hold on user's team + const [teamRow] = await db + .select({ legalHold: schema.teams.legalHold }) + .from(schema.teams) + .where(eq(schema.teams.id, targetUser.team)); + if (teamRow?.legalHold) { + return reply.status(409).send({ error: "User or team is under legal hold" }); + } + + // Execute purge + await purgeUserData(targetUserId); + + // Emit audit event (admin as actor, target = purged userId) + await auditFromRequest(request)("GDPR_USER_PURGED", { + adminId: user.id, + username: user.username, + targetUserId, + }); + + return reply.send({ success: true, purgedUserId: targetUserId }); + }, + ); + + // DELETE /api/v1/enterprise/teams/:id/purge -- GDPR team-level erasure + app.delete( + "/api/v1/enterprise/teams/:id/purge", + async ( + request: FastifyRequest<{ Params: { id: string }; Body: unknown }>, + reply: FastifyReply, + ) => { + const user = await requirePermission("compliance:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("gdpr_lifecycle"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "GDPR data purge requires an enterprise license with the gdpr_lifecycle feature", + }); + } + + // Validate confirmation body + const parsed = purgeBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Purge requires explicit confirmation", + details: parsed.error.issues, + }); + } + + const targetTeamId = request.params.id; + + // Check team exists + const [team] = await db + .select({ id: schema.teams.id, legalHold: schema.teams.legalHold }) + .from(schema.teams) + .where(eq(schema.teams.id, targetTeamId)); + if (!team) { + return reply.status(404).send({ error: "Team not found" }); + } + + // Check team's legalHold + if (team.legalHold) { + return reply.status(409).send({ error: "Team is under legal hold" }); + } + + // Get all users in the team + const teamUsers = await db + .select({ id: schema.users.id, legalHold: schema.users.legalHold }) + .from(schema.users) + .where(eq(schema.users.team, targetTeamId)); + + // Check none of the users have individual legalHold + const heldUser = teamUsers.find((u) => u.legalHold); + if (heldUser) { + return reply.status(409).send({ error: "A team member is under individual legal hold" }); + } + + // Purge each team member + for (const member of teamUsers) { + await purgeUserData(member.id); + } + + // Delete the team itself + await db.delete(schema.teams).where(eq(schema.teams.id, targetTeamId)); + + // Emit audit event + await auditFromRequest(request)("GDPR_TEAM_PURGED", { + adminId: user.id, + username: user.username, + targetTeamId, + purgedUsers: teamUsers.length, + }); + + return reply.send({ + success: true, + purgedTeamId: targetTeamId, + purgedUsers: teamUsers.length, + }); + }, + ); + + app.log.info("Enterprise GDPR routes registered"); +} diff --git a/apps/api/src/routes/enterprise/index.ts b/apps/api/src/routes/enterprise/index.ts new file mode 100644 index 00000000..5d9cd0e9 --- /dev/null +++ b/apps/api/src/routes/enterprise/index.ts @@ -0,0 +1,22 @@ +import type { FastifyInstance } from "fastify"; +import { registerAuditExport } from "./audit-export.js"; +import { registerConfigRoutes } from "./config.js"; +import { registerGdprRoutes } from "./gdpr.js"; +import { registerIpAllowlistRoutes } from "./ip-allowlist.js"; +import { registerLegalHoldRoutes } from "./legal-hold.js"; +import { registerScimRoutes } from "./scim.js"; +import { registerSiemRoutes } from "./siem.js"; +import { registerUpgradeRoutes } from "./upgrade.js"; +import { registerWebhookRoutes } from "./webhooks.js"; + +export async function registerEnterpriseRoutes(app: FastifyInstance) { + await registerAuditExport(app); + await registerConfigRoutes(app); + await registerGdprRoutes(app); + await registerIpAllowlistRoutes(app); + await registerLegalHoldRoutes(app); + await registerScimRoutes(app); + await registerSiemRoutes(app); + await registerUpgradeRoutes(app); + await registerWebhookRoutes(app); +} diff --git a/apps/api/src/routes/enterprise/ip-allowlist.ts b/apps/api/src/routes/enterprise/ip-allowlist.ts new file mode 100644 index 00000000..10001e9d --- /dev/null +++ b/apps/api/src/routes/enterprise/ip-allowlist.ts @@ -0,0 +1,135 @@ +/** + * Admin API for managing the enterprise IP allowlist. + * + * GET /api/v1/enterprise/ip-allowlist -- current list + * PUT /api/v1/enterprise/ip-allowlist -- update (with self-lockout prevention) + */ +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { db, schema } from "../../db/index.js"; +import { auditFromRequest } from "../../lib/audit.js"; +import { requirePermission } from "../../permissions.js"; +import { isValidCidr, publishAllowlistRefresh } from "../../plugins/ip-allowlist.js"; + +const SETTINGS_KEY = "ipAllowlist"; + +const updateSchema = z.object({ + cidrs: z.array(z.string().min(1).max(45)).max(1000), +}); + +export async function registerIpAllowlistRoutes(app: FastifyInstance): Promise { + // GET /api/v1/enterprise/ip-allowlist + app.get( + "/api/v1/enterprise/ip-allowlist", + async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("security:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("ip_allowlist"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "IP allowlisting requires an enterprise license with the ip_allowlist feature", + }); + } + + const [row] = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, SETTINGS_KEY)); + + const cidrs = row ? (JSON.parse(row.value) as string[]) : []; + return reply.send({ cidrs }); + }, + ); + + // PUT /api/v1/enterprise/ip-allowlist + app.put( + "/api/v1/enterprise/ip-allowlist", + async (request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) => { + const user = await requirePermission("security:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("ip_allowlist"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "IP allowlisting requires an enterprise license with the ip_allowlist feature", + }); + } + + const parsed = updateSchema.safeParse(request.body); + if (!parsed.success) { + return reply + .status(400) + .send({ error: "Invalid request body", details: parsed.error.issues }); + } + + const { cidrs } = parsed.data; + + // Validate each CIDR entry + const invalid = cidrs.filter((c) => !isValidCidr(c)); + if (invalid.length > 0) { + return reply.status(400).send({ + error: `Invalid CIDR entries: ${invalid.join(", ")}`, + code: "INVALID_CIDR", + }); + } + + // Self-lockout prevention: if the new list is non-empty, ensure the + // admin's current IP would still be allowed. + if (cidrs.length > 0) { + const { buildBlockList, isIpAllowed } = await import("../../plugins/ip-allowlist.js"); + const bl = buildBlockList(cidrs); + if (bl && !isIpAllowed(request.ip, bl)) { + return reply.status(400).send({ + error: `Your current IP (${request.ip}) would be blocked by this allowlist. Add it before saving.`, + code: "SELF_LOCKOUT", + }); + } + } + + // Persist + const value = JSON.stringify(cidrs); + const now = new Date(); + + const [existing] = await db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, SETTINGS_KEY)); + + if (existing) { + await db + .update(schema.settings) + .set({ value, updatedAt: now }) + .where(eq(schema.settings.key, SETTINGS_KEY)); + } else { + await db.insert(schema.settings).values({ key: SETTINGS_KEY, value }); + } + + // Notify all instances to reload + await publishAllowlistRefresh(); + + await auditFromRequest(request)("IP_ALLOWLIST_UPDATED", { + adminId: user.id, + username: user.username, + count: cidrs.length, + }); + + return reply.send({ ok: true, count: cidrs.length }); + }, + ); +} diff --git a/apps/api/src/routes/enterprise/legal-hold.ts b/apps/api/src/routes/enterprise/legal-hold.ts new file mode 100644 index 00000000..54ac3bc8 --- /dev/null +++ b/apps/api/src/routes/enterprise/legal-hold.ts @@ -0,0 +1,112 @@ +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { db, schema } from "../../db/index.js"; +import { auditFromRequest } from "../../lib/audit.js"; +import { requirePermission } from "../../permissions.js"; + +const holdSchema = z.object({ + targetType: z.enum(["user", "team"]), + targetId: z.string().min(1), + hold: z.boolean(), +}); + +export async function registerLegalHoldRoutes(app: FastifyInstance): Promise { + // PUT /api/v1/enterprise/legal-hold -- set or release a hold + app.put( + "/api/v1/enterprise/legal-hold", + async (request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) => { + const user = await requirePermission("compliance:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("legal_hold"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply + .status(403) + .send({ error: "Legal hold requires an enterprise license with the legal_hold feature" }); + } + + const parsed = holdSchema.safeParse(request.body); + if (!parsed.success) { + return reply + .status(400) + .send({ error: "Invalid request body", details: parsed.error.issues }); + } + + const { targetType, targetId, hold } = parsed.data; + + if (targetType === "user") { + const [existing] = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.id, targetId)); + if (!existing) { + return reply.status(404).send({ error: "User not found" }); + } + await db + .update(schema.users) + .set({ legalHold: hold, updatedAt: new Date() }) + .where(eq(schema.users.id, targetId)); + } else { + const [existing] = await db + .select({ id: schema.teams.id }) + .from(schema.teams) + .where(eq(schema.teams.id, targetId)); + if (!existing) { + return reply.status(404).send({ error: "Team not found" }); + } + await db.update(schema.teams).set({ legalHold: hold }).where(eq(schema.teams.id, targetId)); + } + + await auditFromRequest(request)(hold ? "LEGAL_HOLD_APPLIED" : "LEGAL_HOLD_RELEASED", { + adminId: user.id, + username: user.username, + targetType, + targetId, + }); + + return reply.send({ success: true, targetType, targetId, hold }); + }, + ); + + // GET /api/v1/enterprise/legal-hold -- list current holds + app.get("/api/v1/enterprise/legal-hold", async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("compliance:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("legal_hold"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply + .status(403) + .send({ error: "Legal hold requires an enterprise license with the legal_hold feature" }); + } + + const heldUsers = await db + .select({ id: schema.users.id, username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.legalHold, true)); + + const heldTeams = await db + .select({ id: schema.teams.id, name: schema.teams.name }) + .from(schema.teams) + .where(eq(schema.teams.legalHold, true)); + + return reply.send({ users: heldUsers, teams: heldTeams }); + }); + + app.log.info("Enterprise legal hold routes registered"); +} diff --git a/apps/api/src/routes/enterprise/scim.ts b/apps/api/src/routes/enterprise/scim.ts new file mode 100644 index 00000000..d0d720a3 --- /dev/null +++ b/apps/api/src/routes/enterprise/scim.ts @@ -0,0 +1,1194 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { db, schema } from "../../db/index.js"; +import { sharedRedis } from "../../jobs/connection.js"; +import { auditLog } from "../../lib/audit.js"; +import { getSettingString, upsertSetting } from "../../lib/settings-helpers.js"; +import { requirePermission } from "../../permissions.js"; +import { hashPassword, verifyPassword } from "../../plugins/auth.js"; + +// ── SCIM Error Format ──────────────────────────────────────────── + +function scimError(status: number, detail: string) { + return { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail, + status, + }; +} + +// ── SCIM Bearer Token Auth ─────────────────────────────────────── + +async function scimAuth(request: FastifyRequest, reply: FastifyReply): Promise { + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + reply.status(401).send(scimError(401, "Bearer token required")); + return false; + } + + const token = authHeader.slice(7); + const tokenHash = await getSettingString("scim_token_hash", ""); + if (!tokenHash) { + reply.status(401).send(scimError(401, "SCIM not configured")); + return false; + } + + const valid = await verifyPassword(token, tokenHash); + if (!valid) { + reply.status(401).send(scimError(401, "Invalid token")); + return false; + } + + // Rate limit: 1000 req/min per SCIM token + const redis = sharedRedis(); + const rateLimitKey = `ratelimit:scim:${tokenHash.slice(0, 16)}`; + const count = await redis.incr(rateLimitKey); + if (count === 1) await redis.expire(rateLimitKey, 60); + if (count > 1000) { + reply.status(429).send(scimError(429, "SCIM rate limit exceeded (1000 req/min)")); + return false; + } + + return true; +} + +// ── Enterprise Feature Gate ────────────────────────────────────── + +async function requireScimFeature(reply: FastifyReply): Promise { + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("scim"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + reply + .status(403) + .send( + scimError(403, "SCIM provisioning requires an enterprise license with the scim feature"), + ); + return false; + } + return true; +} + +// ── SCIM Resource Mappers ──────────────────────────────────────── + +interface ScimUser { + schemas: string[]; + id: string; + userName: string; + externalId?: string; + active: boolean; + emails: Array<{ value: string; primary: boolean }>; + name: { formatted: string }; + groups: Array<{ value: string; display: string }>; + meta: { + resourceType: string; + created?: string; + lastModified?: string; + }; +} + +function toScimUser( + user: { + id: string; + username: string; + email: string | null; + externalId: string | null; + role: string; + team: string; + legalHold: boolean; + passwordHash: string | null; + createdAt: Date; + updatedAt: Date; + }, + teamName?: string, +): ScimUser { + return { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], + id: user.id, + userName: user.username, + ...(user.externalId ? { externalId: user.externalId } : {}), + active: user.role !== "disabled" && !user.role.startsWith("disabled:"), + emails: user.email ? [{ value: user.email, primary: true }] : [], + name: { formatted: user.username }, + groups: user.team ? [{ value: user.team, display: teamName ?? user.team }] : [], + meta: { + resourceType: "User", + created: user.createdAt?.toISOString(), + lastModified: user.updatedAt?.toISOString(), + }, + }; +} + +interface ScimGroup { + schemas: string[]; + id: string; + displayName: string; + members: Array<{ value: string; display: string }>; + meta: { + resourceType: string; + created?: string; + }; +} + +function toScimGroup( + team: { id: string; name: string; createdAt: Date }, + members: Array<{ id: string; username: string }>, +): ScimGroup { + return { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], + id: team.id, + displayName: team.name, + members: members.map((m) => ({ value: m.id, display: m.username })), + meta: { + resourceType: "Group", + created: team.createdAt?.toISOString(), + }, + }; +} + +// ── SCIM Filter Parser ─────────────────────────────────────────── + +function parseScimFilter(filter: string): { attribute: string; value: string } | null { + // Support: attribute eq "value" + const match = filter.match(/^(\w+)\s+eq\s+"([^"]*)"$/i); + if (!match) return null; + return { attribute: match[1], value: match[2] }; +} + +// ── SCIM List Response ─────────────────────────────────────────── + +function scimListResponse( + resources: unknown[], + totalResults: number, + startIndex: number, + schema: string, +) { + return { + schemas: [schema], + totalResults, + startIndex, + itemsPerPage: resources.length, + Resources: resources, + }; +} + +// ── Route Registration ─────────────────────────────────────────── + +export async function registerScimRoutes(app: FastifyInstance): Promise { + // ── Token Management Endpoints ──────────────────────────────── + + // POST /api/v1/enterprise/scim/token -- generate a SCIM bearer token + app.post( + "/api/v1/enterprise/scim/token", + async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("users:manage")(request, reply); + if (!user) return; + if (!(await requireScimFeature(reply))) return; + + const token = randomBytes(32).toString("hex"); + const hash = await hashPassword(token); + await upsertSetting("scim_token_hash", hash); + + await auditLog( + request.log, + "SETTINGS_UPDATED", + { setting: "scim_token" }, + request.ip, + request.id, + ); + + return reply.status(201).send({ + token, + message: "Save this token -- it cannot be retrieved again", + }); + }, + ); + + // DELETE /api/v1/enterprise/scim/token -- revoke the SCIM bearer token + app.delete( + "/api/v1/enterprise/scim/token", + async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("users:manage")(request, reply); + if (!user) return; + if (!(await requireScimFeature(reply))) return; + + await db.delete(schema.settings).where(eq(schema.settings.key, "scim_token_hash")); + + await auditLog( + request.log, + "SETTINGS_UPDATED", + { setting: "scim_token", action: "revoked" }, + request.ip, + request.id, + ); + + return reply.status(204).send(); + }, + ); + + // ── Discovery Endpoints (no auth required) ───────────────────── + + app.get( + "/api/v1/scim/v2/ServiceProviderConfig", + async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.send({ + schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + documentationUri: "https://docs.snapotter.com/enterprise/scim", + patch: { supported: true }, + bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 }, + filter: { supported: true, maxResults: 200 }, + changePassword: { supported: false }, + sort: { supported: false }, + etag: { supported: false }, + authenticationSchemes: [ + { + type: "oauthbearertoken", + name: "OAuth Bearer Token", + description: "Authentication scheme using the OAuth Bearer Token Standard", + specUri: "https://www.rfc-editor.org/info/rfc6750", + primary: true, + }, + ], + meta: { + resourceType: "ServiceProviderConfig", + location: "/api/v1/scim/v2/ServiceProviderConfig", + }, + }); + }, + ); + + app.get("/api/v1/scim/v2/Schemas", async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.send({ + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + totalResults: 2, + startIndex: 1, + itemsPerPage: 2, + Resources: [userSchema(), groupSchema()], + }); + }); + + app.get( + "/api/v1/scim/v2/ResourceTypes", + async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.send({ + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + totalResults: 2, + startIndex: 1, + itemsPerPage: 2, + Resources: [ + { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + id: "User", + name: "User", + endpoint: "/api/v1/scim/v2/Users", + schema: "urn:ietf:params:scim:schemas:core:2.0:User", + meta: { resourceType: "ResourceType", location: "/api/v1/scim/v2/ResourceTypes/User" }, + }, + { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + id: "Group", + name: "Group", + endpoint: "/api/v1/scim/v2/Groups", + schema: "urn:ietf:params:scim:schemas:core:2.0:Group", + meta: { resourceType: "ResourceType", location: "/api/v1/scim/v2/ResourceTypes/Group" }, + }, + ], + }); + }, + ); + + // ── User Operations ──────────────────────────────────────────── + + // POST /api/v1/scim/v2/Users -- create user + app.post("/api/v1/scim/v2/Users", async (request: FastifyRequest, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const body = request.body as Record; + const userName = body.userName as string | undefined; + const externalId = body.externalId as string | undefined; + const active = body.active !== false; // default true + const emails = body.emails as Array<{ value: string; primary?: boolean }> | undefined; + if (!userName) { + return reply.status(400).send(scimError(400, "userName is required")); + } + + // Check for duplicate username + const [existing] = await db + .select() + .from(schema.users) + .where(eq(schema.users.username, userName)); + + if (existing) { + return reply.status(409).send(scimError(409, "User already exists")); + } + + const id = randomUUID(); + const email = emails?.find((e) => e.primary)?.value ?? emails?.[0]?.value ?? null; + + // Resolve default team + const [defaultTeam] = await db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, "Default")); + const teamId = defaultTeam?.id ?? "default-team-00000000"; + + const now = new Date(); + await db.insert(schema.users).values({ + id, + username: userName, + email, + externalId: externalId ?? null, + role: active ? "user" : "disabled", + team: teamId, + authProvider: "scim", + mustChangePassword: false, + createdAt: now, + updatedAt: now, + }); + + await auditLog( + request.log, + "SCIM_USER_PROVISIONED", + { + userId: id, + username: userName, + externalId, + }, + request.ip, + request.id, + ); + + const user = { + id, + username: userName, + email, + externalId: externalId ?? null, + role: active ? "user" : "disabled", + team: teamId, + legalHold: false, + passwordHash: null, + createdAt: now, + updatedAt: now, + }; + + return reply.status(201).send(toScimUser(user, defaultTeam?.name)); + }); + + // GET /api/v1/scim/v2/Users/:id -- get user + app.get( + "/api/v1/scim/v2/Users/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const { id } = request.params; + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, id)); + + if (!user) { + return reply.status(404).send(scimError(404, "User not found")); + } + + const [team] = await db + .select({ name: schema.teams.name }) + .from(schema.teams) + .where(eq(schema.teams.id, user.team)); + + return reply.send(toScimUser(user, team?.name)); + }, + ); + + // GET /api/v1/scim/v2/Users -- list users with filter + app.get( + "/api/v1/scim/v2/Users", + async ( + request: FastifyRequest<{ + Querystring: { filter?: string; startIndex?: string; count?: string }; + }>, + reply: FastifyReply, + ) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const filter = (request.query as Record).filter; + const startIndex = Math.max( + 1, + parseInt((request.query as Record).startIndex ?? "1", 10), + ); + const count = Math.min( + 200, + Math.max(1, parseInt((request.query as Record).count ?? "100", 10)), + ); + + let users: Array; + + if (filter) { + const parsed = parseScimFilter(filter); + if (!parsed) { + return reply.status(400).send(scimError(400, "Unsupported filter syntax")); + } + + if (parsed.attribute === "userName") { + users = await db + .select() + .from(schema.users) + .where(eq(schema.users.username, parsed.value)); + } else if (parsed.attribute === "externalId") { + users = await db + .select() + .from(schema.users) + .where(eq(schema.users.externalId, parsed.value)); + } else { + return reply + .status(400) + .send(scimError(400, `Unsupported filter attribute: ${parsed.attribute}`)); + } + } else { + users = await db.select().from(schema.users); + } + + const totalResults = users.length; + const offset = startIndex - 1; + const paged = users.slice(offset, offset + count); + + // Build team name lookup + const teamIds = [...new Set(paged.map((u) => u.team))]; + const teamRows = teamIds.length > 0 ? await db.select().from(schema.teams) : []; + const teamNameById = new Map(teamRows.map((t) => [t.id, t.name])); + + const resources = paged.map((u) => toScimUser(u, teamNameById.get(u.team))); + + return reply.send( + scimListResponse( + resources, + totalResults, + startIndex, + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ), + ); + }, + ); + + // PUT /api/v1/scim/v2/Users/:id -- replace user + app.put( + "/api/v1/scim/v2/Users/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const { id } = request.params; + const [existing] = await db.select().from(schema.users).where(eq(schema.users.id, id)); + + if (!existing) { + return reply.status(404).send(scimError(404, "User not found")); + } + + const body = request.body as Record; + const userName = body.userName as string | undefined; + const externalId = body.externalId as string | undefined; + const active = body.active !== false; + const emails = body.emails as Array<{ value: string; primary?: boolean }> | undefined; + + const updates: Record = { updatedAt: new Date() }; + + if (userName && userName !== existing.username) { + // Check for username conflict + const [conflict] = await db + .select() + .from(schema.users) + .where(eq(schema.users.username, userName)); + if (conflict && conflict.id !== id) { + return reply.status(409).send(scimError(409, "userName already taken")); + } + updates.username = userName; + } + + if (externalId !== undefined) { + updates.externalId = externalId; + } + + const email = emails?.find((e) => e.primary)?.value ?? emails?.[0]?.value; + if (email !== undefined) { + updates.email = email; + } + + // Handle active/deactivation (preserve original role through disable/enable cycle) + if (active && existing.role.startsWith("disabled:")) { + updates.role = existing.role.slice("disabled:".length); + } else if (active && existing.role === "disabled") { + updates.role = "user"; // fallback when no previous role stored + } else if (!active && !existing.role.startsWith("disabled")) { + updates.role = `disabled:${existing.role}`; + // Revoke all sessions on deactivation + await db.delete(schema.sessions).where(eq(schema.sessions.userId, id)); + } + + await db.update(schema.users).set(updates).where(eq(schema.users.id, id)); + + const [updated] = await db.select().from(schema.users).where(eq(schema.users.id, id)); + const [team] = await db + .select({ name: schema.teams.name }) + .from(schema.teams) + .where(eq(schema.teams.id, updated.team)); + + await auditLog( + request.log, + "SCIM_USER_UPDATED", + { + userId: id, + username: updated.username, + changes: Object.keys(updates).filter((k) => k !== "updatedAt"), + }, + request.ip, + request.id, + ); + + return reply.send(toScimUser(updated, team?.name)); + }, + ); + + // PATCH /api/v1/scim/v2/Users/:id -- partial update + app.patch( + "/api/v1/scim/v2/Users/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const { id } = request.params; + const [existing] = await db.select().from(schema.users).where(eq(schema.users.id, id)); + + if (!existing) { + return reply.status(404).send(scimError(404, "User not found")); + } + + const body = request.body as { + schemas?: string[]; + Operations?: Array<{ + op: string; + path?: string; + value?: unknown; + }>; + }; + + const operations = body.Operations ?? []; + const updates: Record = { updatedAt: new Date() }; + + for (const op of operations) { + const opType = op.op.toLowerCase(); + + if (opType === "replace" || opType === "add") { + if ( + op.path === "active" || + (!op.path && + typeof op.value === "object" && + op.value !== null && + "active" in (op.value as Record)) + ) { + const activeVal = + op.path === "active" ? op.value : (op.value as Record).active; + const active = activeVal === true || activeVal === "true" || activeVal === "True"; + if (active && existing.role.startsWith("disabled:")) { + updates.role = existing.role.slice("disabled:".length); + } else if (active && existing.role === "disabled") { + updates.role = "user"; // fallback when no previous role stored + } else if (!active && !existing.role.startsWith("disabled")) { + updates.role = `disabled:${existing.role}`; + await db.delete(schema.sessions).where(eq(schema.sessions.userId, id)); + } + } + + if (op.path === "userName") { + updates.username = op.value as string; + } else if (op.path === "externalId") { + updates.externalId = op.value as string; + } else if (op.path === "emails" || op.path === 'emails[type eq "work"].value') { + const emails = Array.isArray(op.value) + ? (op.value as Array<{ value: string; primary?: boolean }>) + : [{ value: op.value as string, primary: true }]; + updates.email = emails.find((e) => e.primary)?.value ?? emails[0]?.value; + } else if (op.path === "name.formatted" || op.path === "displayName") { + // name.formatted maps to username display; no separate display name column + } + + // Handle valueless replace (bulk value object) + if (!op.path && typeof op.value === "object" && op.value !== null) { + const valObj = op.value as Record; + if (valObj.userName) updates.username = valObj.userName as string; + if (valObj.externalId !== undefined) updates.externalId = valObj.externalId as string; + if (valObj.emails) { + const emails = valObj.emails as Array<{ value: string; primary?: boolean }>; + updates.email = emails.find((e) => e.primary)?.value ?? emails[0]?.value; + } + } + } else if (opType === "remove") { + if (op.path === "externalId") { + updates.externalId = null; + } else if (op.path === "emails") { + updates.email = null; + } + } + } + + await db.update(schema.users).set(updates).where(eq(schema.users.id, id)); + + const [updated] = await db.select().from(schema.users).where(eq(schema.users.id, id)); + const [team] = await db + .select({ name: schema.teams.name }) + .from(schema.teams) + .where(eq(schema.teams.id, updated.team)); + + await auditLog( + request.log, + "SCIM_USER_UPDATED", + { + userId: id, + username: updated.username, + operations: operations.map((o) => ({ op: o.op, path: o.path })), + }, + request.ip, + request.id, + ); + + return reply.send(toScimUser(updated, team?.name)); + }, + ); + + // DELETE /api/v1/scim/v2/Users/:id -- deactivate user (soft delete) + app.delete( + "/api/v1/scim/v2/Users/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const { id } = request.params; + const [user] = await db.select().from(schema.users).where(eq(schema.users.id, id)); + + if (!user) { + return reply.status(404).send(scimError(404, "User not found")); + } + + // Soft-delete: preserve original role so reactivation can restore it + await db + .update(schema.users) + .set({ + role: `disabled:${user.role}`, + passwordHash: null, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, id)); + + // Revoke all sessions + await db.delete(schema.sessions).where(eq(schema.sessions.userId, id)); + + // Revoke all API keys + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, id)); + + await auditLog( + request.log, + "SCIM_USER_DEPROVISIONED", + { + userId: id, + username: user.username, + }, + request.ip, + request.id, + ); + + return reply.status(204).send(); + }, + ); + + // ── Group Operations ─────────────────────────────────────────── + + // POST /api/v1/scim/v2/Groups -- create team + app.post("/api/v1/scim/v2/Groups", async (request: FastifyRequest, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const body = request.body as Record; + const displayName = body.displayName as string | undefined; + const members = body.members as Array<{ value: string }> | undefined; + + if (!displayName) { + return reply.status(400).send(scimError(400, "displayName is required")); + } + + // Check for duplicate team name + const [existing] = await db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, displayName)); + + if (existing) { + return reply.status(409).send(scimError(409, "Group already exists")); + } + + const id = randomUUID(); + const now = new Date(); + + await db.insert(schema.teams).values({ + id, + name: displayName, + createdAt: now, + }); + + // Assign members to the team + if (members && members.length > 0) { + for (const member of members) { + await db + .update(schema.users) + .set({ team: id, updatedAt: new Date() }) + .where(eq(schema.users.id, member.value)); + } + } + + // Fetch actual members + const teamMembers = await db + .select({ id: schema.users.id, username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.team, id)); + + await auditLog( + request.log, + "SCIM_GROUP_SYNCED", + { + teamId: id, + teamName: displayName, + action: "created", + memberCount: teamMembers.length, + }, + request.ip, + request.id, + ); + + return reply + .status(201) + .send(toScimGroup({ id, name: displayName, createdAt: now }, teamMembers)); + }); + + // GET /api/v1/scim/v2/Groups/:id -- get team + app.get( + "/api/v1/scim/v2/Groups/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const { id } = request.params; + const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); + + if (!team) { + return reply.status(404).send(scimError(404, "Group not found")); + } + + const members = await db + .select({ id: schema.users.id, username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.team, id)); + + return reply.send(toScimGroup(team, members)); + }, + ); + + // GET /api/v1/scim/v2/Groups -- list teams + app.get( + "/api/v1/scim/v2/Groups", + async ( + request: FastifyRequest<{ + Querystring: { filter?: string; startIndex?: string; count?: string }; + }>, + reply: FastifyReply, + ) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const filter = (request.query as Record).filter; + const startIndex = Math.max( + 1, + parseInt((request.query as Record).startIndex ?? "1", 10), + ); + const count = Math.min( + 200, + Math.max(1, parseInt((request.query as Record).count ?? "100", 10)), + ); + + let teams: Array; + + if (filter) { + const parsed = parseScimFilter(filter); + if (!parsed) { + return reply.status(400).send(scimError(400, "Unsupported filter syntax")); + } + if (parsed.attribute === "displayName") { + teams = await db.select().from(schema.teams).where(eq(schema.teams.name, parsed.value)); + } else { + return reply + .status(400) + .send(scimError(400, `Unsupported filter attribute: ${parsed.attribute}`)); + } + } else { + teams = await db.select().from(schema.teams); + } + + const totalResults = teams.length; + const offset = startIndex - 1; + const paged = teams.slice(offset, offset + count); + + // Fetch members for each team + const resources: ScimGroup[] = []; + for (const team of paged) { + const members = await db + .select({ id: schema.users.id, username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.team, team.id)); + resources.push(toScimGroup(team, members)); + } + + return reply.send( + scimListResponse( + resources, + totalResults, + startIndex, + "urn:ietf:params:scim:api:messages:2.0:ListResponse", + ), + ); + }, + ); + + // PUT /api/v1/scim/v2/Groups/:id -- replace team + app.put( + "/api/v1/scim/v2/Groups/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const { id } = request.params; + const [existing] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); + + if (!existing) { + return reply.status(404).send(scimError(404, "Group not found")); + } + + const body = request.body as Record; + const displayName = body.displayName as string | undefined; + const members = body.members as Array<{ value: string }> | undefined; + + if (displayName && displayName !== existing.name) { + // Check for name conflict + const [conflict] = await db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, displayName)); + if (conflict && conflict.id !== id) { + return reply.status(409).send(scimError(409, "Group name already taken")); + } + await db.update(schema.teams).set({ name: displayName }).where(eq(schema.teams.id, id)); + } + + // Replace membership: remove all current members, add new ones + if (members !== undefined) { + // Find the default team to move removed members to + const [defaultTeam] = await db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, "Default")); + const fallbackTeamId = defaultTeam?.id ?? "default-team-00000000"; + + // Move current members out of this team + await db + .update(schema.users) + .set({ team: fallbackTeamId, updatedAt: new Date() }) + .where(eq(schema.users.team, id)); + + // Add new members + for (const member of members) { + await db + .update(schema.users) + .set({ team: id, updatedAt: new Date() }) + .where(eq(schema.users.id, member.value)); + } + } + + const [updatedTeam] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); + const teamMembers = await db + .select({ id: schema.users.id, username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.team, id)); + + await auditLog( + request.log, + "SCIM_GROUP_SYNCED", + { + teamId: id, + teamName: updatedTeam.name, + action: "replaced", + memberCount: teamMembers.length, + }, + request.ip, + request.id, + ); + + return reply.send(toScimGroup(updatedTeam, teamMembers)); + }, + ); + + // PATCH /api/v1/scim/v2/Groups/:id -- update members + app.patch( + "/api/v1/scim/v2/Groups/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const { id } = request.params; + const [existing] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); + + if (!existing) { + return reply.status(404).send(scimError(404, "Group not found")); + } + + const body = request.body as { + schemas?: string[]; + Operations?: Array<{ + op: string; + path?: string; + value?: unknown; + }>; + }; + + const operations = body.Operations ?? []; + + for (const op of operations) { + const opType = op.op.toLowerCase(); + + if (opType === "add" && op.path === "members") { + const members = Array.isArray(op.value) + ? (op.value as Array<{ value: string }>) + : [op.value as { value: string }]; + for (const member of members) { + await db + .update(schema.users) + .set({ team: id, updatedAt: new Date() }) + .where(eq(schema.users.id, member.value)); + } + } else if (opType === "remove" && op.path) { + // Parse path like: members[value eq "userId"] + const memberMatch = op.path.match(/^members\[value\s+eq\s+"([^"]+)"\]$/i); + if (memberMatch) { + const userId = memberMatch[1]; + // Move removed member to default team + const [defaultTeam] = await db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, "Default")); + const fallbackTeamId = defaultTeam?.id ?? "default-team-00000000"; + await db + .update(schema.users) + .set({ team: fallbackTeamId, updatedAt: new Date() }) + .where(and(eq(schema.users.id, userId), eq(schema.users.team, id))); + } + } else if (opType === "replace") { + if (op.path === "displayName") { + const newName = op.value as string; + if (newName) { + await db.update(schema.teams).set({ name: newName }).where(eq(schema.teams.id, id)); + } + } else if (op.path === "members") { + // Full member replacement + const members = Array.isArray(op.value) ? (op.value as Array<{ value: string }>) : []; + const [defaultTeam] = await db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, "Default")); + const fallbackTeamId = defaultTeam?.id ?? "default-team-00000000"; + + // Remove all current members + await db + .update(schema.users) + .set({ team: fallbackTeamId, updatedAt: new Date() }) + .where(eq(schema.users.team, id)); + + // Add new members + for (const member of members) { + await db + .update(schema.users) + .set({ team: id, updatedAt: new Date() }) + .where(eq(schema.users.id, member.value)); + } + } + } + } + + const [updatedTeam] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); + const teamMembers = await db + .select({ id: schema.users.id, username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.team, id)); + + await auditLog( + request.log, + "SCIM_GROUP_SYNCED", + { + teamId: id, + teamName: updatedTeam.name, + action: "patched", + memberCount: teamMembers.length, + }, + request.ip, + request.id, + ); + + return reply.send(toScimGroup(updatedTeam, teamMembers)); + }, + ); + + // DELETE /api/v1/scim/v2/Groups/:id -- delete team + app.delete( + "/api/v1/scim/v2/Groups/:id", + async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + if (!(await scimAuth(request, reply))) return; + if (!(await requireScimFeature(reply))) return; + + const { id } = request.params; + const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id)); + + if (!team) { + return reply.status(404).send(scimError(404, "Group not found")); + } + + // Move members to default team + const [defaultTeam] = await db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, "Default")); + const fallbackTeamId = defaultTeam?.id ?? "default-team-00000000"; + + await db + .update(schema.users) + .set({ team: fallbackTeamId, updatedAt: new Date() }) + .where(eq(schema.users.team, id)); + + // Delete the team + await db.delete(schema.teams).where(eq(schema.teams.id, id)); + + await auditLog( + request.log, + "SCIM_GROUP_SYNCED", + { + teamId: id, + teamName: team.name, + action: "deleted", + }, + request.ip, + request.id, + ); + + return reply.status(204).send(); + }, + ); + + app.log.info("Enterprise SCIM 2.0 routes registered"); +} + +// ── SCIM Schema Definitions ────────────────────────────────────── + +function userSchema() { + return { + id: "urn:ietf:params:scim:schemas:core:2.0:User", + name: "User", + description: "User Account", + attributes: [ + { + name: "userName", + type: "string", + multiValued: false, + required: true, + mutability: "readWrite", + uniqueness: "server", + }, + { + name: "emails", + type: "complex", + multiValued: true, + required: false, + mutability: "readWrite", + subAttributes: [ + { name: "value", type: "string", mutability: "readWrite" }, + { name: "primary", type: "boolean", mutability: "readWrite" }, + ], + }, + { + name: "name", + type: "complex", + multiValued: false, + required: false, + mutability: "readWrite", + subAttributes: [{ name: "formatted", type: "string", mutability: "readWrite" }], + }, + { + name: "active", + type: "boolean", + multiValued: false, + required: false, + mutability: "readWrite", + }, + { + name: "externalId", + type: "string", + multiValued: false, + required: false, + mutability: "readWrite", + }, + { + name: "groups", + type: "complex", + multiValued: true, + required: false, + mutability: "readOnly", + subAttributes: [ + { name: "value", type: "string", mutability: "readOnly" }, + { name: "display", type: "string", mutability: "readOnly" }, + ], + }, + ], + meta: { + resourceType: "Schema", + location: "/api/v1/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User", + }, + }; +} + +function groupSchema() { + return { + id: "urn:ietf:params:scim:schemas:core:2.0:Group", + name: "Group", + description: "Group", + attributes: [ + { + name: "displayName", + type: "string", + multiValued: false, + required: true, + mutability: "readWrite", + }, + { + name: "members", + type: "complex", + multiValued: true, + required: false, + mutability: "readWrite", + subAttributes: [ + { name: "value", type: "string", mutability: "readWrite" }, + { name: "display", type: "string", mutability: "readOnly" }, + ], + }, + ], + meta: { + resourceType: "Schema", + location: "/api/v1/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group", + }, + }; +} diff --git a/apps/api/src/routes/enterprise/siem.ts b/apps/api/src/routes/enterprise/siem.ts new file mode 100644 index 00000000..d151a32d --- /dev/null +++ b/apps/api/src/routes/enterprise/siem.ts @@ -0,0 +1,147 @@ +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { env } from "../../config.js"; +import { db, schema } from "../../db/index.js"; +import { auditFromRequest } from "../../lib/audit.js"; +import { encrypt, isEncrypted } from "../../lib/encryption.js"; +import { requirePermission } from "../../permissions.js"; + +const configSchema = z.object({ + webhookUrl: z.string().url(), + authHeader: z.string().default(""), + flushIntervalSeconds: z.number().min(10).max(3600).default(30), + enabled: z.boolean(), +}); + +export type SiemConfig = z.infer; + +const SETTINGS_KEY = "siem_config"; + +export async function registerSiemRoutes(app: FastifyInstance): Promise { + // GET /api/v1/enterprise/siem/config + app.get( + "/api/v1/enterprise/siem/config", + async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("webhooks:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("siem_forwarding"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "SIEM forwarding requires an enterprise license with the siem_forwarding feature", + }); + } + + const [row] = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, SETTINGS_KEY)); + + if (!row) { + return reply.send({ + webhookUrl: "", + authHeader: "", + flushIntervalSeconds: 30, + enabled: false, + }); + } + + const config = JSON.parse(row.value) as SiemConfig; + return reply.send({ + ...config, + authHeader: config.authHeader ? "***" : "", + }); + }, + ); + + // PUT /api/v1/enterprise/siem/config + app.put( + "/api/v1/enterprise/siem/config", + async (request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) => { + const user = await requirePermission("webhooks:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("siem_forwarding"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "SIEM forwarding requires an enterprise license with the siem_forwarding feature", + }); + } + + const parsed = configSchema.safeParse(request.body); + if (!parsed.success) { + return reply + .status(400) + .send({ error: "Invalid SIEM config", details: parsed.error.issues }); + } + + const config = { ...parsed.data }; + + // Encrypt the auth header before storage if encryption key is set + if (config.authHeader && env.DATA_ENCRYPTION_KEY) { + config.authHeader = await encrypt(config.authHeader, env.DATA_ENCRYPTION_KEY); + } + + const value = JSON.stringify(config); + const now = new Date(); + + const [existing] = await db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, SETTINGS_KEY)); + + if (existing) { + await db + .update(schema.settings) + .set({ value, updatedAt: now }) + .where(eq(schema.settings.key, SETTINGS_KEY)); + } else { + await db.insert(schema.settings).values({ key: SETTINGS_KEY, value }); + } + + await auditFromRequest(request)("SETTINGS_UPDATED", { + adminId: user.id, + username: user.username, + keys: [SETTINGS_KEY], + }); + + return reply.send({ ok: true }); + }, + ); + + app.log.info("Enterprise SIEM routes registered"); +} + +/** + * Read the raw SIEM config from the settings table. + * Returns null if not configured. + */ +export async function readSiemConfig(): Promise { + const [row] = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, SETTINGS_KEY)); + + if (!row) return null; + + try { + return JSON.parse(row.value) as SiemConfig; + } catch { + return null; + } +} diff --git a/apps/api/src/routes/enterprise/upgrade.ts b/apps/api/src/routes/enterprise/upgrade.ts new file mode 100644 index 00000000..be6b85bb --- /dev/null +++ b/apps/api/src/routes/enterprise/upgrade.ts @@ -0,0 +1,171 @@ +import { readFileSync } from "node:fs"; +import { statfs } from "node:fs/promises"; +import { join } from "node:path"; +import { APP_VERSION } from "@snapotter/shared"; +import { sql } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { env } from "../../config.js"; +import { db, schema } from "../../db/index.js"; +import { pingRedis } from "../../jobs/connection.js"; +import { requirePermission } from "../../permissions.js"; + +function requireUpgradeFeature(reply: FastifyReply): Promise { + return (async () => { + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + if (isFeatureEnabled("upgrade_management")) return true; + } catch { + // Enterprise package not available + } + reply.status(403).send({ + error: "Upgrade management requires a license with the upgrade_management feature", + }); + return false; + })(); +} + +function readJournal(): { version: string; entries: JournalEntry[] } | null { + try { + const journalPath = join(process.cwd(), "drizzle", "meta", "_journal.json"); + return JSON.parse(readFileSync(journalPath, "utf-8")); + } catch { + return null; + } +} + +interface JournalEntry { + idx: number; + version: string; + when: number; + tag: string; + breakpoints: boolean; +} + +export async function registerUpgradeRoutes(app: FastifyInstance): Promise { + // GET /api/v1/admin/version + app.get("/api/v1/admin/version", async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("system:health")(request, reply); + if (!user) return; + if (!(await requireUpgradeFeature(reply))) return; + + const journal = readJournal(); + const lastEntry = journal?.entries.at(-1); + + return reply.send({ + version: APP_VERSION, + buildDate: process.env.BUILD_DATE || null, + nodeVersion: process.version, + schemaVersion: lastEntry ? String(lastEntry.idx).padStart(4, "0") : null, + pendingMigrations: 0, + }); + }); + + // GET /api/v1/admin/migrations/pending + app.get( + "/api/v1/admin/migrations/pending", + async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("system:health")(request, reply); + if (!user) return; + if (!(await requireUpgradeFeature(reply))) return; + + const journal = readJournal(); + if (!journal) { + return reply.status(500).send({ error: "Could not read migration journal" }); + } + + // Try to read applied migrations from DB + let applied: string[] = []; + try { + const rows = await db.execute( + sql`SELECT hash FROM __drizzle_migrations ORDER BY created_at`, + ); + applied = (rows.rows as { hash: string }[]).map((r) => r.hash); + } catch { + // Table may not exist yet + } + + const migrations = journal.entries.map((entry) => ({ + idx: entry.idx, + tag: entry.tag, + when: entry.when, + applied: applied.includes(entry.tag), + })); + + const appliedCount = migrations.filter((m) => m.applied).length; + + return reply.send({ + migrations, + appliedCount, + totalCount: journal.entries.length, + }); + }, + ); + + // GET /api/v1/admin/upgrade-check + app.get("/api/v1/admin/upgrade-check", async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("system:health")(request, reply); + if (!user) return; + if (!(await requireUpgradeFeature(reply))) return; + + // Check database connectivity + let dbOk = false; + try { + await db.select().from(schema.settings).limit(1); + dbOk = true; + } catch { + /* db unreachable */ + } + + // Check Redis connectivity + let redisOk = false; + try { + redisOk = await pingRedis(); + } catch { + /* redis unreachable */ + } + + // Check in-flight jobs + let activeCount = 0; + let noInFlightJobs = true; + try { + const [row] = await db + .select({ count: sql`count(*)::int` }) + .from(schema.jobs) + .where(sql`${schema.jobs.status} IN ('queued', 'processing')`); + activeCount = row?.count ?? 0; + noInFlightJobs = activeCount === 0; + } catch { + // If we can't query, assume there may be jobs + noInFlightJobs = false; + } + + // Check disk space (> 1 GB free) + const MIN_FREE_BYTES = 1024 * 1024 * 1024; // 1 GB + let diskOk = true; + let freeGb = 0; + if (env.STORAGE_MODE !== "s3") { + try { + const stats = await statfs(env.WORKSPACE_PATH); + const freeBytes = stats.bfree * stats.bsize; + freeGb = Math.round((freeBytes / (1024 * 1024 * 1024)) * 100) / 100; + diskOk = freeBytes > MIN_FREE_BYTES; + } catch { + // Path doesn't exist -- skip check + } + } + + const ready = diskOk && noInFlightJobs && dbOk && redisOk; + + return reply.send({ + ready, + checks: { + diskSpace: { ok: diskOk, freeGb }, + inFlightJobs: { ok: noInFlightJobs, activeCount }, + databaseConnected: { ok: dbOk }, + redisConnected: { ok: redisOk }, + }, + }); + }); + + app.log.info("Enterprise upgrade management routes registered"); +} diff --git a/apps/api/src/routes/enterprise/webhooks.ts b/apps/api/src/routes/enterprise/webhooks.ts new file mode 100644 index 00000000..63c16151 --- /dev/null +++ b/apps/api/src/routes/enterprise/webhooks.ts @@ -0,0 +1,277 @@ +/** + * Unified webhook destination management (enterprise). + * + * CRUD for webhook destinations stored as a JSON array in the settings table + * under the key "webhook_destinations". Each destination can be type "siem" + * (forward audit events) or "alerts" (receive admin alert conditions). + * + * Gated behind the `webhooks:manage` permission + `admin_alerts` enterprise feature. + */ +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { env } from "../../config.js"; +import { db, schema } from "../../db/index.js"; +import { auditFromRequest } from "../../lib/audit.js"; +import { encrypt } from "../../lib/encryption.js"; +import { deliverWebhook } from "../../lib/webhook-delivery.js"; +import { requirePermission } from "../../permissions.js"; + +const SETTINGS_KEY = "webhook_destinations"; + +const webhookSchema = z.object({ + name: z.string().min(1).max(100), + url: z.string().url(), + authHeader: z.string().default(""), + eventFilter: z.array(z.string()).default([]), + batchIntervalSeconds: z.number().min(10).max(3600).default(30), + enabled: z.boolean().default(true), + type: z.enum(["siem", "alerts"]).default("alerts"), +}); + +export type WebhookDestination = z.infer; + +async function readDestinations(): Promise { + const [row] = await db + .select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, SETTINGS_KEY)); + + if (!row) return []; + + try { + return JSON.parse(row.value) as WebhookDestination[]; + } catch { + return []; + } +} + +async function writeDestinations(destinations: WebhookDestination[]): Promise { + const value = JSON.stringify(destinations); + const now = new Date(); + + const [existing] = await db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, SETTINGS_KEY)); + + if (existing) { + await db + .update(schema.settings) + .set({ value, updatedAt: now }) + .where(eq(schema.settings.key, SETTINGS_KEY)); + } else { + await db.insert(schema.settings).values({ key: SETTINGS_KEY, value }); + } +} + +async function checkFeatureGate(reply: FastifyReply): Promise { + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("admin_alerts"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + reply.status(403).send({ + error: "Webhook management requires a license with the admin_alerts feature", + }); + return false; + } + return true; +} + +export async function registerWebhookRoutes(app: FastifyInstance): Promise { + // GET /api/v1/enterprise/webhooks -- list all destinations + app.get("/api/v1/enterprise/webhooks", async (request: FastifyRequest, reply: FastifyReply) => { + const user = await requirePermission("webhooks:manage")(request, reply); + if (!user) return; + if (!(await checkFeatureGate(reply))) return; + + const destinations = await readDestinations(); + + // Mask auth headers in response + const masked = destinations.map((d) => ({ + ...d, + authHeader: d.authHeader ? "***" : "", + })); + + return reply.send({ destinations: masked }); + }); + + // POST /api/v1/enterprise/webhooks -- create a destination + app.post( + "/api/v1/enterprise/webhooks", + async (request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) => { + const user = await requirePermission("webhooks:manage")(request, reply); + if (!user) return; + if (!(await checkFeatureGate(reply))) return; + + const parsed = webhookSchema.safeParse(request.body); + if (!parsed.success) { + return reply + .status(400) + .send({ error: "Invalid webhook destination", details: parsed.error.issues }); + } + + const dest = { ...parsed.data }; + + // Encrypt the auth header before storage + if (dest.authHeader && env.DATA_ENCRYPTION_KEY) { + dest.authHeader = await encrypt(dest.authHeader, env.DATA_ENCRYPTION_KEY); + } + + const destinations = await readDestinations(); + destinations.push(dest); + await writeDestinations(destinations); + + await auditFromRequest(request)("SETTINGS_UPDATED", { + adminId: user.id, + username: user.username, + keys: [SETTINGS_KEY], + action: "webhook_created", + name: dest.name, + }); + + return reply.status(201).send({ ok: true, index: destinations.length - 1 }); + }, + ); + + // PUT /api/v1/enterprise/webhooks/:index -- update a destination by index + app.put( + "/api/v1/enterprise/webhooks/:index", + async ( + request: FastifyRequest<{ Params: { index: string }; Body: unknown }>, + reply: FastifyReply, + ) => { + const user = await requirePermission("webhooks:manage")(request, reply); + if (!user) return; + if (!(await checkFeatureGate(reply))) return; + + const index = parseInt(request.params.index, 10); + if (Number.isNaN(index) || index < 0) { + return reply.status(400).send({ error: "Invalid index" }); + } + + const parsed = webhookSchema.safeParse(request.body); + if (!parsed.success) { + return reply + .status(400) + .send({ error: "Invalid webhook destination", details: parsed.error.issues }); + } + + const destinations = await readDestinations(); + if (index >= destinations.length) { + return reply.status(404).send({ error: "Webhook destination not found" }); + } + + const dest = { ...parsed.data }; + + // Encrypt the auth header before storage + if (dest.authHeader && env.DATA_ENCRYPTION_KEY) { + dest.authHeader = await encrypt(dest.authHeader, env.DATA_ENCRYPTION_KEY); + } + + destinations[index] = dest; + await writeDestinations(destinations); + + await auditFromRequest(request)("SETTINGS_UPDATED", { + adminId: user.id, + username: user.username, + keys: [SETTINGS_KEY], + action: "webhook_updated", + name: dest.name, + }); + + return reply.send({ ok: true }); + }, + ); + + // DELETE /api/v1/enterprise/webhooks/:index -- remove a destination + app.delete( + "/api/v1/enterprise/webhooks/:index", + async (request: FastifyRequest<{ Params: { index: string } }>, reply: FastifyReply) => { + const user = await requirePermission("webhooks:manage")(request, reply); + if (!user) return; + if (!(await checkFeatureGate(reply))) return; + + const index = parseInt(request.params.index, 10); + if (Number.isNaN(index) || index < 0) { + return reply.status(400).send({ error: "Invalid index" }); + } + + const destinations = await readDestinations(); + if (index >= destinations.length) { + return reply.status(404).send({ error: "Webhook destination not found" }); + } + + const removed = destinations.splice(index, 1)[0]; + await writeDestinations(destinations); + + await auditFromRequest(request)("SETTINGS_UPDATED", { + adminId: user.id, + username: user.username, + keys: [SETTINGS_KEY], + action: "webhook_deleted", + name: removed.name, + }); + + return reply.send({ ok: true }); + }, + ); + + // POST /api/v1/enterprise/webhooks/:index/test -- send a test ping + app.post( + "/api/v1/enterprise/webhooks/:index/test", + async (request: FastifyRequest<{ Params: { index: string } }>, reply: FastifyReply) => { + const user = await requirePermission("webhooks:manage")(request, reply); + if (!user) return; + if (!(await checkFeatureGate(reply))) return; + + const index = parseInt(request.params.index, 10); + if (Number.isNaN(index) || index < 0) { + return reply.status(400).send({ error: "Invalid index" }); + } + + const destinations = await readDestinations(); + if (index >= destinations.length) { + return reply.status(404).send({ error: "Webhook destination not found" }); + } + + const dest = destinations[index]; + + // Decrypt auth header if needed + let authHeader = dest.authHeader; + if (authHeader) { + try { + const { isEncrypted, decrypt } = await import("../../lib/encryption.js"); + if (isEncrypted(authHeader) && env.DATA_ENCRYPTION_KEY) { + const decrypted = await decrypt(authHeader, env.DATA_ENCRYPTION_KEY); + authHeader = decrypted ?? ""; + } + } catch { + // Use raw value if decryption fails + } + } + + const testEvent = [ + { + condition: "test_ping", + message: "This is a test webhook from SnapOtter", + timestamp: new Date().toISOString(), + }, + ]; + + const result = await deliverWebhook(dest.url, authHeader, testEvent, { maxRetries: 0 }); + + return reply.send({ + ok: result.success, + statusCode: result.statusCode, + error: result.error, + }); + }, + ); + + app.log.info("Enterprise webhook routes registered"); +} diff --git a/apps/api/src/routes/roles.ts b/apps/api/src/routes/roles.ts index 96e548e9..c78cb37c 100644 --- a/apps/api/src/routes/roles.ts +++ b/apps/api/src/routes/roles.ts @@ -4,7 +4,7 @@ import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db, schema } from "../db/index.js"; -import { auditLog } from "../lib/audit.js"; +import { auditFromRequest } from "../lib/audit.js"; import { requirePermission } from "../permissions.js"; const ALL_PERMISSIONS: Permission[] = [ @@ -38,16 +38,26 @@ const roleNameField = z ), ); +const toolPermissionsSchema = z + .object({ + mode: z.enum(["category", "tool"]), + allowed: z.array(z.string()), + }) + .nullable() + .optional(); + const createRoleSchema = z.object({ name: roleNameField, description: z.string().max(500).optional(), permissions: z.array(z.string()).min(1, "At least one permission is required"), + toolPermissions: toolPermissionsSchema, }); const updateRoleSchema = z.object({ name: roleNameField.optional(), description: z.string().max(500).optional(), permissions: z.array(z.string()).optional(), + toolPermissions: toolPermissionsSchema, }); export async function rolesRoutes(app: FastifyInstance): Promise { @@ -72,6 +82,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { name: r.name, description: r.description, permissions: r.permissions, + toolPermissions: r.toolPermissions ?? null, isBuiltin: r.isBuiltin, userCount: countMap.get(r.name) ?? 0, createdAt: r.createdAt.toISOString(), @@ -92,7 +103,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { code: "VALIDATION_ERROR", }); } - const { name, description, permissions } = parsed.data; + const { name, description, permissions, toolPermissions } = parsed.data; const invalid = permissions.filter((p) => !ALL_PERMISSIONS.includes(p as Permission)); if (invalid.length > 0) { @@ -112,17 +123,23 @@ export async function rolesRoutes(app: FastifyInstance): Promise { name, description: description?.trim() ?? "", permissions, + toolPermissions: toolPermissions ?? null, isBuiltin: false, createdBy: user.id, }); - await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }); + await auditFromRequest(request)("ROLE_CREATED", { + adminId: user.id, + roleId: id, + roleName: name, + }); return reply.status(201).send({ id, name, description: description?.trim() ?? "", permissions, + toolPermissions: toolPermissions ?? null, isBuiltin: false, }); }); @@ -175,6 +192,9 @@ export async function rolesRoutes(app: FastifyInstance): Promise { } updates.permissions = body.permissions; } + if (body.toolPermissions !== undefined) { + updates.toolPermissions = body.toolPermissions ?? null; + } await db.transaction(async (tx) => { if (body.name) { @@ -185,7 +205,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { } await tx.update(schema.roles).set(updates).where(eq(schema.roles.id, id)); }); - await auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id }); + await auditFromRequest(request)("ROLE_UPDATED", { adminId: user.id, roleId: id }); return reply.send({ ok: true }); }, @@ -216,7 +236,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { .where(eq(schema.users.role, role.name)); await tx.delete(schema.roles).where(eq(schema.roles.id, id)); }); - await auditLog(request.log, "ROLE_DELETED", { + await auditFromRequest(request)("ROLE_DELETED", { adminId: user.id, roleId: id, roleName: role.name, diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 2b29dcc6..530f2961 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -9,8 +9,10 @@ import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; +import { env } from "../config.js"; import { db, schema } from "../db/index.js"; -import { auditLog } from "../lib/audit.js"; +import { auditFromRequest } from "../lib/audit.js"; +import { decrypt, encrypt, isEncrypted } from "../lib/encryption.js"; import { requirePermission } from "../permissions.js"; import { requireAuth } from "../plugins/auth.js"; @@ -18,7 +20,30 @@ const settingsBodySchema = z.record(z.string().min(1), z.unknown()); const HTML_TAG_PATTERN = /<[a-z/!?][^>]*>/i; -const SENSITIVE_KEYS = new Set(["cookie_secret", "instance_id"]); +const SENSITIVE_KEYS = new Set([ + "cookie_secret", + "instance_id", + "oidc_client_secret", + "saml_idp_certificate", + "siem_webhook_auth", +]); + +async function encryptIfSensitive(key: string, value: string): Promise { + if (!env.DATA_ENCRYPTION_KEY || !SENSITIVE_KEYS.has(key)) return value; + return encrypt(value, env.DATA_ENCRYPTION_KEY); +} + +async function decryptIfNeeded(value: string): Promise { + if (!isEncrypted(value)) return value; + if (!env.DATA_ENCRYPTION_KEY) return value; + return ( + (await decrypt( + value, + env.DATA_ENCRYPTION_KEY, + env.DATA_ENCRYPTION_KEY_PREVIOUS || undefined, + )) ?? value + ); +} export async function settingsRoutes(app: FastifyInstance): Promise { // GET /api/v1/settings — Get all settings as a key-value object @@ -32,7 +57,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { const settings: Record = {}; for (const row of rows) { if (!isAdmin && SENSITIVE_KEYS.has(row.key)) continue; - settings[row.key] = row.value; + settings[row.key] = await decryptIfNeeded(row.value); } return reply.send({ settings }); @@ -74,6 +99,8 @@ export async function settingsRoutes(app: FastifyInstance): Promise { const now = new Date(); for (const { key, strValue } of entries) { + const storedValue = await encryptIfSensitive(key, strValue); + // Upsert: insert or update on conflict const [existing] = await db .select() @@ -83,15 +110,15 @@ export async function settingsRoutes(app: FastifyInstance): Promise { if (existing) { await db .update(schema.settings) - .set({ value: strValue, updatedAt: now }) + .set({ value: storedValue, updatedAt: now }) .where(eq(schema.settings.key, key)); } else { - await db.insert(schema.settings).values({ key, value: strValue }); + await db.insert(schema.settings).values({ key, value: storedValue }); } } if (entries.length > 0) { - await auditLog(request.log, "SETTINGS_UPDATED", { + await auditFromRequest(request)("SETTINGS_UPDATED", { adminId: admin.id, username: admin.username, keys: entries.map((e) => e.key), @@ -125,7 +152,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { return reply.send({ key: row.key, - value: row.value, + value: await decryptIfNeeded(row.value), updatedAt: row.updatedAt.toISOString(), }); }, diff --git a/apps/api/src/routes/teams.ts b/apps/api/src/routes/teams.ts index 21d804ab..219a46d4 100644 --- a/apps/api/src/routes/teams.ts +++ b/apps/api/src/routes/teams.ts @@ -14,7 +14,7 @@ import { z } from "zod"; import { db, schema } from "../db/index.js"; import { requirePermission } from "../permissions.js"; -const teamNameSchema = z.object({ +const teamBodySchema = z.object({ name: z .string({ required_error: "Team name is required" }) .transform((v) => v.trim()) @@ -24,6 +24,8 @@ const teamNameSchema = z.object({ .min(1, "Team name is required") .max(50, "Team name must be 50 characters or fewer"), ), + storageQuota: z.number().int().positive().nullable().optional(), + retentionHours: z.number().int().positive().nullable().optional(), }); export async function teamsRoutes(app: FastifyInstance): Promise { @@ -36,6 +38,8 @@ export async function teamsRoutes(app: FastifyInstance): Promise { .select({ id: schema.teams.id, name: schema.teams.name, + storageQuota: schema.teams.storageQuota, + retentionHours: schema.teams.retentionHours, memberCount: sql`(SELECT COUNT(*)::int FROM users WHERE users.team = ${schema.teams.id})`, createdAt: schema.teams.createdAt, }) @@ -54,14 +58,14 @@ export async function teamsRoutes(app: FastifyInstance): Promise { const admin = await requirePermission("teams:manage")(request, reply); if (!admin) return; - const parsed = teamNameSchema.safeParse(request.body); + const parsed = teamBodySchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.issues.map((i) => i.message).join("; "), code: "VALIDATION_ERROR", }); } - const trimmedName = parsed.data.name; + const { name: trimmedName, storageQuota, retentionHours } = parsed.data; // Check for duplicate name (case-insensitive) const [existing] = await db @@ -75,9 +79,19 @@ export async function teamsRoutes(app: FastifyInstance): Promise { const id = randomUUID(); - await db.insert(schema.teams).values({ id, name: trimmedName }); + await db.insert(schema.teams).values({ + id, + name: trimmedName, + storageQuota: storageQuota ?? null, + retentionHours: retentionHours ?? null, + }); - return reply.status(201).send({ id, name: trimmedName }); + return reply.status(201).send({ + id, + name: trimmedName, + storageQuota: storageQuota ?? null, + retentionHours: retentionHours ?? null, + }); }); // PUT /api/v1/teams/:id — Rename team (admin only) @@ -94,14 +108,14 @@ export async function teamsRoutes(app: FastifyInstance): Promise { return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); } - const parsed = teamNameSchema.safeParse(request.body); + const parsed = teamBodySchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.issues.map((i) => i.message).join("; "), code: "VALIDATION_ERROR", }); } - const trimmedName = parsed.data.name; + const { name: trimmedName, storageQuota, retentionHours } = parsed.data; // Check for duplicate name (case-insensitive), excluding current team const [duplicate] = await db @@ -115,7 +129,11 @@ export async function teamsRoutes(app: FastifyInstance): Promise { return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" }); } - await db.update(schema.teams).set({ name: trimmedName }).where(eq(schema.teams.id, id)); + const updateFields: Partial = { name: trimmedName }; + if (storageQuota !== undefined) updateFields.storageQuota = storageQuota; + if (retentionHours !== undefined) updateFields.retentionHours = retentionHours; + + await db.update(schema.teams).set(updateFields).where(eq(schema.teams.id, id)); return reply.send({ ok: true }); }, diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 7df69020..8b3ca7a2 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -3,15 +3,18 @@ import { mkdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { extname, join } from "node:path"; import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared"; +import { and, inArray, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { z } from "zod"; import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; import { enqueueToolJob, waitForJob } from "../jobs/enqueue.js"; import { trackEvent } from "../lib/analytics.js"; import { formatZodErrors, stripInternalPaths } from "../lib/errors.js"; import { isToolInstalled } from "../lib/feature-status.js"; import { getObjectBuffer, putObject } from "../lib/object-storage.js"; import { resolveToolPool, shouldSkipSyncWindow } from "../lib/pool.js"; +import { getSettingNumber } from "../lib/settings-helpers.js"; import { type ReceivedUpload, receiveUpload } from "../lib/upload-stream.js"; import { InputValidationError } from "../modality/contract.js"; import { inputHandlerFor } from "../modality/input-handler.js"; @@ -201,6 +204,15 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig `/api/v1/tools/${config.toolId}`, { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (request: FastifyRequest, reply: FastifyReply) => { + // Check per-tool access before processing uploads + const authUser = getAuthUser(request); + if (authUser) { + const { hasToolAccess } = await import("../permissions.js"); + if (!(await hasToolAccess(authUser.role, config.toolId))) { + return reply.status(403).send({ error: "You don't have permission to use this tool" }); + } + } + const jobId = randomUUID(); const maxInputs = config.maxInputs ?? 1; let filename = "image"; @@ -432,6 +444,29 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig }); } + // Check per-user concurrent job limit before enqueuing + const userId = getAuthUser(request)?.id ?? null; + const maxConcurrent = await getSettingNumber("maxConcurrentJobsPerUser", 0); + if (maxConcurrent > 0 && userId) { + const activeJobs = await db + .select({ count: sql`count(*)::int` }) + .from(schema.jobs) + .where( + and( + sql`${schema.jobs.userId} = ${userId}`, + inArray(schema.jobs.status, ["queued", "processing"]), + ), + ); + + if (activeJobs[0].count >= maxConcurrent) { + return reply.status(429).send({ + error: "Too many concurrent jobs. Please wait for existing jobs to complete.", + activeJobs: activeJobs[0].count, + limit: maxConcurrent, + }); + } + } + const startTime = Date.now(); const pool = resolveToolPool(config.toolId); @@ -442,7 +477,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig await enqueueToolJob({ jobId, toolId: config.toolId, - userId: getAuthUser(request)?.id ?? null, + userId, pool, inputRefs, filename, @@ -469,6 +504,26 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig is_ai_tool: getBundleForTool(config.toolId) !== null, }); + // Fire-and-forget: audit log must never block the response + import("../lib/audit.js") + .then(({ isToolAuditEnabled, auditFromRequest }) => + isToolAuditEnabled().then((enabled) => { + if (!enabled) return; + const user = getAuthUser(request); + return auditFromRequest(request)("TOOL_EXECUTED", { + userId: user?.id, + username: user?.username, + toolId: config.toolId, + inputFileCount: received.length, + totalInputSize: received.reduce((sum, r) => sum + r.size, 0), + outputFormat: (settings as Record)?.format ?? null, + status: "success", + durationMs: Date.now() - startTime, + }); + }), + ) + .catch(() => {}); + return reply.send({ jobId, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 7497b174..89f80b3d 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -17,7 +17,7 @@ import sharp from "sharp"; import { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; -import { auditLog } from "../lib/audit.js"; +import { auditFromRequest } from "../lib/audit.js"; import { deleteStoredFile, deleteThumbnail, @@ -82,27 +82,64 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) { } /** - * Check whether a user has exceeded their storage quota. - * Returns the total bytes used, or throws if the quota is exceeded. + * Check whether a user (and their team) has exceeded their storage quota. + * Uses the pre-computed storageUsed counter on the users table. + * Throws with statusCode 413 if the quota is exceeded. */ -async function checkStorageQuota(userId: string | null): Promise { - if (!userId || env.MAX_STORAGE_PER_USER_MB <= 0) return; +async function checkStorageQuota(userId: string | null, additionalBytes = 0): Promise { + if (!userId) return; - const [result] = await db - .select({ total: sql`coalesce(sum(${schema.userFiles.size}), 0)` }) - .from(schema.userFiles) - .where(eq(schema.userFiles.userId, userId)); + const [user] = await db + .select({ + storageUsed: schema.users.storageUsed, + storageQuota: schema.users.storageQuota, + team: schema.users.team, + }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); - const usedBytes = result?.total ?? 0; - const limitBytes = env.MAX_STORAGE_PER_USER_MB * 1024 * 1024; + if (!user) return; + const { storageUsed, storageQuota, team } = user; - if (usedBytes >= limitBytes) { + // Per-user quota: user-level override, then env fallback + const userLimit = + storageQuota ?? + (env.MAX_STORAGE_PER_USER_MB > 0 ? env.MAX_STORAGE_PER_USER_MB * 1024 * 1024 : 0); + if (userLimit > 0 && storageUsed + additionalBytes > userLimit) { const error = new Error( - `Storage quota exceeded. Used ${(usedBytes / (1024 * 1024)).toFixed(1)}MB of ${env.MAX_STORAGE_PER_USER_MB}MB`, + `Storage quota exceeded. Used ${((storageUsed + additionalBytes) / (1024 * 1024)).toFixed(1)}MB of ${(userLimit / (1024 * 1024)).toFixed(1)}MB`, ); (error as Error & { statusCode: number }).statusCode = 413; throw error; } + + // Per-team quota + if (team) { + const [teamRow] = await db + .select({ storageQuota: schema.teams.storageQuota }) + .from(schema.teams) + .where(eq(schema.teams.id, team)) + .limit(1); + + if (teamRow?.storageQuota) { + const [teamUsed] = await db + .select({ + total: sql`coalesce(sum(${schema.users.storageUsed}), 0)`, + }) + .from(schema.users) + .where(eq(schema.users.team, team)); + + const teamTotal = Number(teamUsed.total); + if (teamTotal + additionalBytes > teamRow.storageQuota) { + const error = new Error( + `Team storage quota exceeded. Team used ${((teamTotal + additionalBytes) / (1024 * 1024)).toFixed(1)}MB of ${(teamRow.storageQuota / (1024 * 1024)).toFixed(1)}MB`, + ); + (error as Error & { statusCode: number }).statusCode = 413; + throw error; + } + } + } } // ── Route registration ───────────────────────────────────────────── @@ -224,6 +261,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise { // Sanitize SVG uploads to prevent XXE, SSRF, and script injection const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer; + // Re-check quota with actual file size before persisting + try { + await checkStorageQuota(userId, safeBuffer.length); + } catch (err) { + const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413; + return reply.status(statusCode).send({ error: (err as Error).message }); + } + const safeName = sanitizeFilename(part.filename ?? "upload"); const mimeType = formatToMime(validation.format); @@ -232,6 +277,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { // Create DB record const id = randomUUID(); + const fileSize = safeBuffer.length; try { await db.insert(schema.userFiles).values({ id, @@ -239,7 +285,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { originalName: safeName, storedName, mimeType, - size: safeBuffer.length, + size: fileSize, width: validation.width, height: validation.height, version: 1, @@ -250,6 +296,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise { return reply.status(409).send({ error: "Failed to save file record" }); } + // Increment the user's pre-computed storage counter + if (userId) { + await db + .update(schema.users) + .set({ storageUsed: sql`${schema.users.storageUsed} + ${fileSize}` }) + .where(eq(schema.users.id, userId)); + } + const [row] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)); if (row) created.push(serializeFile(row)); @@ -259,7 +313,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { return reply.status(400).send({ error: "No valid files uploaded" }); } - await auditLog(request.log, "FILE_UPLOADED", { + await auditFromRequest(request)("FILE_UPLOADED", { userId, count: created.length, files: created.map((f) => f.originalName), @@ -495,13 +549,15 @@ export async function userFileRoutes(app: FastifyInstance): Promise { .map((f) => f.id); if (validIds.length === 0) { - await auditLog(request.log, "FILE_DELETED", { userId: user.id, count: 0, ids }); + await auditFromRequest(request)("FILE_DELETED", { userId: user.id, count: 0, ids }); return reply.send({ deleted: 0 }); } type DeleteChainRow = { id: string; stored_name: string; + size: number | null; + user_id: string | null; }; // Single recursive CTE to collect all chain members for every valid ID @@ -518,15 +574,15 @@ export async function userFileRoutes(app: FastifyInstance): Promise { SELECT uf.id, uf.parent_id FROM user_files uf INNER JOIN ancestors a ON uf.id = a.parent_id ), - chain(id, stored_name) AS ( - SELECT f.id, f.stored_name FROM user_files f + chain(id, stored_name, size, user_id) AS ( + SELECT f.id, f.stored_name, f.size, f.user_id FROM user_files f WHERE f.id IN (SELECT id FROM ancestors WHERE parent_id IS NULL) UNION ALL - SELECT child.id, child.stored_name + SELECT child.id, child.stored_name, child.size, child.user_id FROM user_files child INNER JOIN chain c ON child.parent_id = c.id ) - SELECT DISTINCT id, stored_name FROM chain + SELECT DISTINCT id, stored_name, size, user_id FROM chain `); const chainRows = cteResult.rows; @@ -542,7 +598,23 @@ export async function userFileRoutes(app: FastifyInstance): Promise { await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds)); } - await auditLog(request.log, "FILE_DELETED", { + // Decrement storageUsed per user (group by userId for files:all scenarios) + const perUserSizes = new Map(); + for (const row of chainRows) { + if (row.user_id && row.size) { + perUserSizes.set(row.user_id, (perUserSizes.get(row.user_id) ?? 0) + row.size); + } + } + for (const [uid, totalSize] of perUserSizes) { + await db + .update(schema.users) + .set({ + storageUsed: sql`GREATEST(0, ${schema.users.storageUsed} - ${totalSize})`, + }) + .where(eq(schema.users.id, uid)); + } + + await auditFromRequest(request)("FILE_DELETED", { userId: user.id, count: chainRows.length, ids, @@ -635,11 +707,20 @@ export async function userFileRoutes(app: FastifyInstance): Promise { // Sanitize SVG results to prevent XXE, SSRF, and script injection const safeResultBuffer = isSvgBuffer(fileBuffer) ? sanitizeSvg(fileBuffer) : fileBuffer; + // Re-check quota with actual file size before persisting + try { + await checkStorageQuota(userId, safeResultBuffer.length); + } catch (err) { + const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413; + return reply.status(statusCode).send({ error: (err as Error).message }); + } + // Persist to disk const storedName = await saveFile(safeResultBuffer, resultName); // Create DB record const id = randomUUID(); + const fileSize = safeResultBuffer.length; try { await db.insert(schema.userFiles).values({ id, @@ -647,7 +728,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { originalName: resultName, storedName, mimeType, - size: safeResultBuffer.length, + size: fileSize, width: validation.width, height: validation.height, version: nextVersion, @@ -658,6 +739,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise { return reply.status(409).send({ error: "Failed to save result record" }); } + // Increment the user's pre-computed storage counter + if (userId) { + await db + .update(schema.users) + .set({ storageUsed: sql`${schema.users.storageUsed} + ${fileSize}` }) + .where(eq(schema.users.id, userId)); + } + const [row] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)); return reply.status(201).send({ file: row ? serializeFile(row) : null }); diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index b910ceca..f7704481 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -338,6 +338,8 @@ interface TeamEntry { id: string; name: string; memberCount: number; + storageQuota: number | null; + retentionHours: number | null; createdAt: string; } @@ -686,6 +688,52 @@ function SystemSection() { +
+

+ {t.settings.dataRetention.title} +

+
+ + updateSetting("tempFileMaxAgeHours", e.target.value)} + aria-label={t.settings.dataRetention.fileMaxAgeHours} + className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" + min={1} + max={8760} + /> + + + updateSetting("jobsRetentionDays", e.target.value)} + aria-label={t.settings.dataRetention.jobsRetentionDays} + className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" + min={0} + /> + + + updateSetting("auditRetentionDays", e.target.value)} + aria-label={t.settings.dataRetention.auditRetentionDays} + className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" + min={0} + /> + +
+ + {hasPermission("settings:write") && } + + ); +} + +function AdminSecuritySettings() { + const { t } = useTranslation(); + const [settings, setSettings] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveMsg, setSaveMsg] = useState(null); + + useEffect(() => { + apiGet<{ settings: Record }>("/v1/settings") + .then((data) => setSettings(data.settings)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const updateSetting = useCallback((key: string, value: string) => { + setSettings((prev) => ({ ...prev, [key]: value })); + }, []); + + const handleSave = useCallback(async () => { + setSaving(true); + setSaveMsg(null); + try { + await apiPut("/v1/settings", settings); + setSaveMsg(t.settings.security.securitySettingsSaved); + } catch { + setSaveMsg(t.settings.security.securitySettingsFailed); + } finally { + setSaving(false); + setTimeout(() => setSaveMsg(null), 3000); + } + }, [settings, t]); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ {t.settings.security.adminHeading} +

+

{t.settings.security.adminDescription}

+
+ + + updateSetting("sessionIdleTimeoutMinutes", e.target.value)} + aria-label={t.settings.security.sessionIdleTimeout} + className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" + min={0} + /> + + + + updateSetting("maxSessionsPerUser", e.target.value)} + aria-label={t.settings.security.maxSessionsPerUser} + className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" + min={0} + /> + + + + + + + + + + + {settings.ssoEnforcement === "true" && ( + + updateSetting("ssoBreakGlassUsername", e.target.value)} + aria-label={t.settings.security.ssoBreakGlassUsername} + className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-40" + placeholder="admin" + /> + + )} + +
+

+ {t.settings.security.passwordPolicyHeading} +

+
+ + + updateSetting("passwordMinLength", e.target.value)} + aria-label={t.settings.security.passwordMinLength} + className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" + min={4} + max={128} + /> + + + + + + + + + + + + + + +
+ + {saveMsg && ( + + {saveMsg} + + )} +
); } @@ -1870,6 +2191,10 @@ function TeamsSection() { const [editingTeamId, setEditingTeamId] = useState(null); const [editingTeamName, setEditingTeamName] = useState(""); const [openMenuId, setOpenMenuId] = useState(null); + const [expandedTeamId, setExpandedTeamId] = useState(null); + const [quotaMb, setQuotaMb] = useState(""); + const [retention, setRetention] = useState(""); + const [savingQuota, setSavingQuota] = useState(false); const [actionMsg, setActionMsg] = useState<{ type: "success" | "error"; text: string } | null>( null, ); @@ -1960,6 +2285,43 @@ function TeamsSection() { [loadTeams], ); + const handleExpandTeam = useCallback( + (tm: TeamEntry) => { + if (expandedTeamId === tm.id) { + setExpandedTeamId(null); + return; + } + setExpandedTeamId(tm.id); + setQuotaMb(tm.storageQuota ? String(Math.round(tm.storageQuota / (1024 * 1024))) : ""); + setRetention(tm.retentionHours ? String(tm.retentionHours) : ""); + }, + [expandedTeamId], + ); + + const handleSaveQuota = useCallback( + async (id: string) => { + setSavingQuota(true); + try { + const body: Record = {}; + const mbVal = quotaMb.trim() ? Number(quotaMb) : 0; + body.storageQuota = mbVal > 0 ? mbVal * 1024 * 1024 : null; + const retVal = retention.trim() ? Number(retention) : 0; + body.retentionHours = retVal > 0 ? retVal : null; + await apiPut(`/v1/teams/${id}`, body); + setActionMsg({ type: "success", text: t.settings.teams.quotaSaved }); + setExpandedTeamId(null); + await loadTeams(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to save"; + setActionMsg({ type: "error", text: msg }); + } finally { + setSavingQuota(false); + setTimeout(() => setActionMsg(null), 3000); + } + }, + [quotaMb, retention, loadTeams], + ); + if (loading) { return (
@@ -2049,97 +2411,177 @@ function TeamsSection() {
) : ( teams.map((tm) => ( -
-
- {editingTeamId === tm.id ? ( + +
+
+ {editingTeamId === tm.id ? ( +
+ setEditingTeamName(e.target.value)} + className="px-2 py-1 rounded border border-border bg-background text-sm text-foreground w-40" + ref={(el) => el?.focus()} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(tm.id); + if (e.key === "Escape") setEditingTeamId(null); + }} + /> + + +
+ ) : ( +
+ + {tm.name} + + {isMobile && ( + + {tm.memberCount} {plural(tm.memberCount, "member", "members")} + + )} +
+ )} +
+ {!isMobile && ( + {tm.memberCount} + )} +
+ + {openMenuId === tm.id && ( +
+ + +
+ +
+ )} +
+
+ {expandedTeamId === tm.id && ( +
+
+
+ + setQuotaMb(e.target.value)} + placeholder="0" + className="w-full px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground" + /> +

+ {t.settings.teams.teamStorageQuotaDesc} +

+
+
+ + setRetention(e.target.value)} + placeholder="0" + className="w-full px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground" + /> +

+ {t.settings.teams.teamRetentionHoursDesc} +

+
+
- setEditingTeamName(e.target.value)} - className="px-2 py-1 rounded border border-border bg-background text-sm text-foreground w-40" - ref={(el) => el?.focus()} - onKeyDown={(e) => { - if (e.key === "Enter") handleRename(tm.id); - if (e.key === "Escape") setEditingTeamId(null); - }} - />
- ) : ( -
- - {tm.name} - - {isMobile && ( - - {tm.memberCount} {plural(tm.memberCount, "member", "members")} - - )} -
- )} -
- {!isMobile && {tm.memberCount}} -
- - {openMenuId === tm.id && ( -
- -
- -
- )} -
-
+
+ )} +
)) )}
@@ -2525,26 +2967,39 @@ function RolesSection() { const AUDIT_ACTIONS = [ "LOGIN_SUCCESS", "LOGIN_FAILED", - "USER_CREATED", - "USER_UPDATED", - "USER_DELETED", + "LOGOUT", "PASSWORD_CHANGED", "PASSWORD_RESET", + "USER_CREATED", + "USER_DELETED", + "USER_UPDATED", + "FILE_UPLOADED", + "FILE_DELETED", "API_KEY_CREATED", "API_KEY_DELETED", "ROLE_CREATED", "ROLE_UPDATED", "ROLE_DELETED", "SETTINGS_UPDATED", + "OIDC_LOGIN_SUCCESS", + "OIDC_USER_CREATED", + "OIDC_USER_LINKED", + "OIDC_LOGIN_FAILED", + "TOOL_EXECUTED", + "BATCH_EXECUTED", + "PIPELINE_EXECUTED", ] as const; interface AuditEntry { id: string; + actorId: string | null; actorUsername: string; action: string; targetType: string | null; targetId: string | null; details: Record | null; + ipAddress: string | null; + requestId: string | null; createdAt: string; } @@ -2646,6 +3101,11 @@ function AuditLogSection() {
{entry.actorUsername} + {entry.ipAddress && ( + + {entry.ipAddress} + + )} {entry.targetType && ( {entry.targetType} @@ -2674,6 +3134,7 @@ function AuditLogSection() { {t.settings.auditLog.tableHeaderUser} + IP {t.settings.auditLog.tableHeaderAction} @@ -2693,6 +3154,9 @@ function AuditLogSection() { {formatRelativeTime(entry.createdAt)} {entry.actorUsername} + + {entry.ipAddress ?? "---"} + {entry.action} @@ -2706,7 +3170,7 @@ function AuditLogSection() { {expandedId === entry.id && entry.details && ( - +
                             {JSON.stringify(entry.details, null, 2)}
                           
diff --git a/apps/web/src/components/settings/usage-section.tsx b/apps/web/src/components/settings/usage-section.tsx index b33cdf2e..a6da7df2 100644 --- a/apps/web/src/components/settings/usage-section.tsx +++ b/apps/web/src/components/settings/usage-section.tsx @@ -12,6 +12,7 @@ interface UsageData { perUser: Array<{ username: string | null; runs: number; bytesIn: string }>; durations: Array<{ pool: string; p50Ms: number | null; p95Ms: number | null }>; storage: { libraryBytes: string; libraryFiles: number }; + teamStorage?: Array<{ teamName: string; totalBytes: string; userCount: number }>; } export function UsageSection() { @@ -260,6 +261,43 @@ export function UsageSection() {
+ + {/* Storage by Team */} + {data.teamStorage && data.teamStorage.length > 0 && ( +
+

+ {t.settings.usage.storageByTeam} +

+ + + + + + + + + + {data.teamStorage.map((row) => ( + + + + + + ))} + +
+ {t.settings.usage.teamColumn} + + {t.settings.usage.storageColumn} + + {t.settings.usage.usersColumn} +
{row.teamName} + {formatFileSize(Number(row.totalBytes))} + + {row.userCount} +
+
+ )} ) : null} diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index 6e879871..6161fcad 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -7,6 +7,7 @@ interface AuthState { authEnabled: boolean; isAuthenticated: boolean; mustChangePassword: boolean; + mfaRequired: boolean; role: string | null; permissions: string[]; analyticsEnabled: boolean | null; @@ -14,6 +15,9 @@ interface AuthState { analyticsConsentRemindAt: number | null; oidcEnabled: boolean; oidcProviderName: string | null; + samlEnabled: boolean; + samlProviderName: string | null; + ssoEnforced: boolean; loginMethod: string | null; hasLocalPassword: boolean; } @@ -41,6 +45,7 @@ export function useAuth() { authEnabled: false, isAuthenticated: false, mustChangePassword: false, + mfaRequired: false, role: null, permissions: [], analyticsEnabled: null, @@ -48,6 +53,9 @@ export function useAuth() { analyticsConsentRemindAt: null, oidcEnabled: false, oidcProviderName: null, + samlEnabled: false, + samlProviderName: null, + ssoEnforced: false, loginMethod: null, hasLocalPassword: false, }); @@ -67,6 +75,7 @@ export function useAuth() { authEnabled: false, isAuthenticated: true, mustChangePassword: false, + mfaRequired: false, role: "admin", permissions: ANON_ADMIN_PERMISSIONS, analyticsEnabled: null, @@ -74,6 +83,9 @@ export function useAuth() { analyticsConsentRemindAt: null, oidcEnabled: false, oidcProviderName: null, + samlEnabled: false, + samlProviderName: null, + ssoEnforced: false, loginMethod: null, hasLocalPassword: false, }); @@ -95,6 +107,7 @@ export function useAuth() { authEnabled: true, isAuthenticated: true, mustChangePassword: mustChange, + mfaRequired: session.user?.mfaRequired === true, role: session.user?.role ?? null, permissions: session.user?.permissions ?? [], analyticsEnabled: session.user?.analyticsEnabled ?? null, @@ -102,6 +115,9 @@ export function useAuth() { analyticsConsentRemindAt: session.user?.analyticsConsentRemindAt ?? null, oidcEnabled: config.oidcEnabled ?? false, oidcProviderName: config.oidcProviderName ?? null, + samlEnabled: config.samlEnabled ?? false, + samlProviderName: config.samlProviderName ?? null, + ssoEnforced: config.ssoEnforced ?? false, loginMethod: session.user?.loginMethod ?? null, hasLocalPassword: session.user?.hasLocalPassword ?? false, }); @@ -113,6 +129,7 @@ export function useAuth() { authEnabled: true, isAuthenticated: false, mustChangePassword: false, + mfaRequired: false, role: null, permissions: [], analyticsEnabled: null, @@ -120,6 +137,9 @@ export function useAuth() { analyticsConsentRemindAt: null, oidcEnabled: config.oidcEnabled ?? false, oidcProviderName: config.oidcProviderName ?? null, + samlEnabled: config.samlEnabled ?? false, + samlProviderName: config.samlProviderName ?? null, + ssoEnforced: config.ssoEnforced ?? false, loginMethod: null, hasLocalPassword: false, }); diff --git a/apps/web/src/pages/login-page.tsx b/apps/web/src/pages/login-page.tsx index b98f3864..e756b850 100644 --- a/apps/web/src/pages/login-page.tsx +++ b/apps/web/src/pages/login-page.tsx @@ -127,24 +127,32 @@ function LanguageSelector() { export function LoginPage() { const { t } = useTranslation(); - const { oidcEnabled, oidcProviderName } = useAuth(); + const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName, ssoEnforced } = useAuth(); const [searchParams] = useSearchParams(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [showMfaPrompt, setShowMfaPrompt] = useState(false); + const [mfaToken, setMfaToken] = useState(""); + const [mfaCode, setMfaCode] = useState(""); + const [mfaLoading, setMfaLoading] = useState(false); + const mfaInputRef = useRef(null); useEffect(() => { - const oidcError = searchParams.get("error"); - if (oidcError) { + const authError = searchParams.get("error"); + if (authError) { const errorMessages: Record = { oidc_auth_failed: t.auth.oidcAuthFailed, oidc_provider_unreachable: t.auth.oidcProviderUnreachable, oidc_session_expired: t.auth.oidcSessionExpired, oidc_user_not_authorized: t.auth.oidcUserNotAuthorized, oidc_user_limit_reached: t.auth.oidcUserLimitReached, + saml_auth_failed: t.auth.samlAuthFailed, + saml_user_not_authorized: t.auth.samlUserNotAuthorized, + saml_user_limit_reached: t.auth.samlUserLimitReached, }; - setError(errorMessages[oidcError] || t.auth.oidcGenericError); + setError(errorMessages[authError] || t.auth.oidcGenericError); } }, [searchParams, t]); @@ -164,6 +172,12 @@ export function LoginPage() { return; } const data = await res.json(); + if (data.requiresMfa) { + setMfaToken(data.mfaToken); + setShowMfaPrompt(true); + setTimeout(() => mfaInputRef.current?.focus(), 100); + return; + } setToken(data.token); localStorage.setItem("snapotter-username", data.user?.username || username); if (data.user?.mustChangePassword) { @@ -178,6 +192,35 @@ export function LoginPage() { } }; + const handleMfaComplete = async () => { + setMfaLoading(true); + setError(""); + try { + const res = await fetch("/api/auth/mfa/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mfaToken, code: mfaCode }), + }); + if (!res.ok) { + setError(t.auth.mfaInvalidCode); + setMfaCode(""); + return; + } + const data = await res.json(); + setToken(data.token); + localStorage.setItem("snapotter-username", data.user?.username || username); + if (data.user?.mustChangePassword) { + window.location.href = "/change-password"; + } else { + window.location.href = "/"; + } + } catch { + setError(t.auth.connectionError); + } finally { + setMfaLoading(false); + } + }; + return (
@@ -188,61 +231,174 @@ export function LoginPage() {

{t.auth.login}

-
-
- + {ssoEnforced && (oidcEnabled || samlEnabled) && ( +
+ {oidcEnabled && ( + + {format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })} + + )} + {samlEnabled && ( + + {format(t.auth.signInWith, { provider: samlProviderName || "SSO" })} + + )} +
+
+ {t.auth.or} +
+
+

+ {t.auth.ssoEnforcedLocalRestricted} +

+
+ )} + {showMfaPrompt ? ( +
+
+
+ +
+
+

{t.auth.mfaRequired}

+
+
setUsername(e.target.value)} - placeholder={t.auth.enterUsername} - className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" - required + inputMode="numeric" + pattern="[0-9]*" + maxLength={8} + autoComplete="one-time-code" + placeholder="000000" + value={mfaCode} + onChange={(e) => setMfaCode(e.target.value.replace(/[^0-9]/g, ""))} + onKeyDown={(e) => { + if (e.key === "Enter" && mfaCode.length >= 6) handleMfaComplete(); + }} + className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground text-center text-2xl font-mono tracking-[0.5em] focus:outline-none focus:ring-2 focus:ring-primary/20" /> + {error &&

{error}

} + +

{t.auth.mfaRecoveryHint}

+
-
- - setPassword(e.target.value)} - placeholder={t.auth.enterPassword} - className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" - required - /> -
- {error &&

{error}

} - - - {oidcEnabled && ( +
+ + setUsername(e.target.value)} + placeholder={t.auth.enterUsername} + className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" + required + /> +
+
+ + setPassword(e.target.value)} + placeholder={t.auth.enterPassword} + className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" + required + /> +
+ {error &&

{error}

} + + + )} + {!ssoEnforced && (oidcEnabled || samlEnabled) && ( <>
{t.auth.or}
- - {format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })} - + {oidcEnabled && ( + + {format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })} + + )} + {samlEnabled && ( + + {format(t.auth.signInWith, { provider: samlProviderName || "SSO" })} + + )} )}
diff --git a/packages/enterprise/src/index.ts b/packages/enterprise/src/index.ts index c374d924..188ede53 100644 --- a/packages/enterprise/src/index.ts +++ b/packages/enterprise/src/index.ts @@ -2,6 +2,7 @@ import { ENTERPRISE_FEATURES, type EnterpriseFeature, type LicensePayload, + PLAN_FEATURES, validateLicense, } from "./license.js"; @@ -31,4 +32,4 @@ export type S3StorageModule = typeof import("./storage-s3.js"); export async function loadS3Storage(): Promise { return import("./storage-s3.js"); } -export { ENTERPRISE_FEATURES, type EnterpriseFeature, type LicensePayload }; +export { ENTERPRISE_FEATURES, type EnterpriseFeature, type LicensePayload, PLAN_FEATURES }; diff --git a/packages/enterprise/src/license.ts b/packages/enterprise/src/license.ts index 621c4bd0..343a2d4c 100644 --- a/packages/enterprise/src/license.ts +++ b/packages/enterprise/src/license.ts @@ -13,12 +13,31 @@ export const ENTERPRISE_FEATURES = [ "audit_export", "mfa", "per_tool_permissions", + "siem_forwarding", + "tamper_resistant_audit", + "legal_hold", + "gdpr_lifecycle", + "team_retention_overrides", + "sso_enforcement", + "ip_allowlist", + "config_export_import", + "upgrade_management", + "admin_alerts", ] as const; export type EnterpriseFeature = (typeof ENTERPRISE_FEATURES)[number]; export const PLAN_FEATURES: Record = { - team: ["saml_sso", "s3_storage", "multi_tenancy"], + team: [ + "saml_sso", + "s3_storage", + "multi_tenancy", + "audit_export", + "siem_forwarding", + "sso_enforcement", + "upgrade_management", + "admin_alerts", + ], enterprise: ENTERPRISE_FEATURES, }; diff --git a/packages/shared/src/audit-events.ts b/packages/shared/src/audit-events.ts new file mode 100644 index 00000000..20d85c11 --- /dev/null +++ b/packages/shared/src/audit-events.ts @@ -0,0 +1,37 @@ +export const CORE_AUDIT_EVENTS = [ + "LOGIN_SUCCESS", + "LOGIN_FAILED", + "LOGOUT", + "PASSWORD_CHANGED", + "PASSWORD_RESET", + "USER_CREATED", + "USER_DELETED", + "USER_UPDATED", + "FILE_UPLOADED", + "FILE_DELETED", + "API_KEY_CREATED", + "API_KEY_DELETED", + "ROLE_CREATED", + "ROLE_UPDATED", + "ROLE_DELETED", + "SETTINGS_UPDATED", + "OIDC_LOGIN_SUCCESS", + "OIDC_USER_CREATED", + "OIDC_USER_LINKED", + "OIDC_LOGIN_FAILED", + "TOOL_EXECUTED", + "BATCH_EXECUTED", + "PIPELINE_EXECUTED", +] as const; + +export type CoreAuditEvent = (typeof CORE_AUDIT_EVENTS)[number]; + +export const ALL_AUDIT_EVENTS = [...CORE_AUDIT_EVENTS] as string[]; + +export function registerAuditEvents(events: string[]) { + for (const e of events) { + if (!ALL_AUDIT_EVENTS.includes(e)) { + ALL_AUDIT_EVENTS.push(e); + } + } +} diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 40c84931..6185e74c 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -2711,6 +2711,33 @@ export const ar: TranslationKeys = { currentPasswordIncorrect: "كلمة المرور الحالية غير صحيحة", changePasswordButton: "تغيير كلمة المرور", loginAttemptLimitNote: "يمكن ضبط حدود محاولات تسجيل الدخول في إعدادات النظام.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "الأعضاء", @@ -2791,6 +2818,12 @@ export const ar: TranslationKeys = { nameRequired: "اسم الفريق مطلوب", nameTooLong: "يجب ألا يتجاوز اسم الفريق 50 حرفًا", duplicateName: "يوجد فريق بهذا الاسم بالفعل", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "الأدوار", @@ -2872,6 +2905,10 @@ export const ar: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "مفاتيح API", @@ -2913,6 +2950,16 @@ export const ar: TranslationKeys = { startupCleanup: "تنظيف عند بدء التشغيل", startupCleanupDescription: "تنظيف الملفات المؤقتة القديمة عند بدء تشغيل الخادم", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "تحليلات المنتج", description: "مشاركة بيانات الاستخدام المجهولة للمساعدة في تحسين SnapOtter.", @@ -2977,6 +3024,19 @@ export const ar: TranslationKeys = { oidcUserNotAuthorized: "حسابك غير مصرح له بالوصول إلى هذا التطبيق. تواصل مع المسؤول.", oidcUserLimitReached: "تم الوصول لحد المستخدمين. تواصل مع المسؤول.", oidcGenericError: "خطأ في المصادقة. يرجى المحاولة مرة أخرى.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "يتم إدارة تغيير كلمة المرور بواسطة مزود الهوية الخاص بك.", enterUsername: "أدخل اسم المستخدم", enterPassword: "أدخل كلمة المرور", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index 6f5fcd03..924fb1fb 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -2727,6 +2727,33 @@ export const de: TranslationKeys = { changePasswordButton: "Passwort aendern", loginAttemptLimitNote: "Anmeldeversuchslimits koennen in den Systemeinstellungen konfiguriert werden.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Personen", @@ -2810,6 +2837,12 @@ export const de: TranslationKeys = { nameRequired: "Teamname ist erforderlich", nameTooLong: "Teamname darf maximal 50 Zeichen lang sein", duplicateName: "Ein Team mit diesem Namen existiert bereits", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Rollen", @@ -2892,6 +2925,10 @@ export const de: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API-Schluessel", @@ -2938,6 +2975,16 @@ export const de: TranslationKeys = { startupCleanup: "Bereinigung beim Start", startupCleanupDescription: "Alte temporaere Dateien beim Serverstart bereinigen", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Produktanalytik", description: "Anonyme Nutzungsdaten teilen, um SnapOtter zu verbessern.", @@ -3004,6 +3051,19 @@ export const de: TranslationKeys = { "Ihr Konto ist nicht fuer den Zugriff auf diese Anwendung autorisiert. Kontaktieren Sie Ihren Administrator.", oidcUserLimitReached: "Benutzerlimit erreicht. Kontaktieren Sie Ihren Administrator.", oidcGenericError: "Authentifizierungsfehler. Bitte versuchen Sie es erneut.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Passwortaenderungen werden von Ihrem Identitaetsanbieter verwaltet.", enterUsername: "Benutzernamen eingeben", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index def472be..a50e8655 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -2675,6 +2675,33 @@ export const en = { currentPasswordIncorrect: "Current password is incorrect", changePasswordButton: "Change Password", loginAttemptLimitNote: "Login attempt limits can be configured in System Settings.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "People", @@ -2756,6 +2783,12 @@ export const en = { nameRequired: "Team name is required", nameTooLong: "Team name must be 50 characters or less", duplicateName: "A team with this name already exists", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Roles", @@ -2837,6 +2870,10 @@ export const en = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API Keys", @@ -2897,6 +2934,16 @@ export const en = { startupCleanup: "Startup Cleanup", startupCleanupDescription: "Clean up old temporary files when the server starts", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Product Analytics", description: "Share anonymous usage data to help improve SnapOtter.", @@ -2943,6 +2990,19 @@ export const en = { "Your account is not authorized to access this application. Contact your administrator.", oidcUserLimitReached: "User limit reached. Contact your administrator.", oidcGenericError: "Authentication error. Please try again.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Password changes are managed by your identity provider.", enterUsername: "Enter username", enterPassword: "Enter your password", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 1de29123..bd258d8e 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -2709,6 +2709,33 @@ export const es: TranslationKeys = { changePasswordButton: "Cambiar contrasena", loginAttemptLimitNote: "Los limites de intentos de inicio de sesion se pueden configurar en Configuracion del sistema.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Personas", @@ -2790,6 +2817,12 @@ export const es: TranslationKeys = { nameRequired: "El nombre del equipo es obligatorio", nameTooLong: "El nombre del equipo debe tener 50 caracteres o menos", duplicateName: "Ya existe un equipo con este nombre", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Roles", @@ -2872,6 +2905,10 @@ export const es: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "Claves API", @@ -2916,6 +2953,16 @@ export const es: TranslationKeys = { startupCleanup: "Limpieza al inicio", startupCleanupDescription: "Limpia archivos temporales antiguos cuando el servidor arranca", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Analitica del producto", description: "Comparte datos de uso anonimos para ayudar a mejorar SnapOtter.", @@ -2982,6 +3029,19 @@ export const es: TranslationKeys = { "Tu cuenta no esta autorizada para acceder a esta aplicacion. Contacta a tu administrador.", oidcUserLimitReached: "Limite de usuarios alcanzado. Contacta a tu administrador.", oidcGenericError: "Error de autenticacion. Intenta de nuevo.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Los cambios de contrasena son administrados por tu proveedor de identidad.", enterUsername: "Ingresa tu nombre de usuario", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index d977c38a..d21d04d8 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -2728,6 +2728,33 @@ export const fr: TranslationKeys = { changePasswordButton: "Changer le mot de passe", loginAttemptLimitNote: "Les limites de tentatives de connexion peuvent etre configurees dans les Parametres systeme.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Personnes", @@ -2810,6 +2837,12 @@ export const fr: TranslationKeys = { nameRequired: "Le nom de l'equipe est requis", nameTooLong: "Le nom de l'equipe doit contenir 50 caracteres ou moins", duplicateName: "Une equipe avec ce nom existe deja", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Roles", @@ -2892,6 +2925,10 @@ export const fr: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "Cles API", @@ -2936,6 +2973,16 @@ export const fr: TranslationKeys = { startupCleanup: "Nettoyage au demarrage", startupCleanupDescription: "Nettoie les anciens fichiers temporaires au demarrage du serveur", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Analytique produit", description: "Partagez des donnees d'utilisation anonymes pour aider a ameliorer SnapOtter.", @@ -3002,6 +3049,19 @@ export const fr: TranslationKeys = { "Votre compte n'est pas autorise a acceder a cette application. Contactez votre administrateur.", oidcUserLimitReached: "Limite d'utilisateurs atteinte. Contactez votre administrateur.", oidcGenericError: "Erreur d'authentification. Veuillez reessayer.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Les modifications de mot de passe sont gerees par votre fournisseur d'identite.", enterUsername: "Saisissez votre nom d'utilisateur", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 5caa03bb..2b8fb1c2 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -2707,6 +2707,33 @@ export const hi: TranslationKeys = { currentPasswordIncorrect: "मौजूदा पासवर्ड गलत है", changePasswordButton: "पासवर्ड बदलें", loginAttemptLimitNote: "लॉगिन प्रयास सीमाएं सिस्टम सेटिंग्स में कॉन्फ़िगर की जा सकती हैं।", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "सदस्य", @@ -2787,6 +2814,12 @@ export const hi: TranslationKeys = { nameRequired: "टीम का नाम आवश्यक है", nameTooLong: "टीम का नाम 50 अक्षरों से कम होना चाहिए", duplicateName: "इस नाम की टीम पहले से मौजूद है", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "भूमिकाएं", @@ -2868,6 +2901,10 @@ export const hi: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API कुंजियां", @@ -2909,6 +2946,16 @@ export const hi: TranslationKeys = { startupCleanup: "स्टार्टअप क्लीनअप", startupCleanupDescription: "सर्वर शुरू होने पर पुरानी अस्थायी फाइलें साफ करें", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "प्रोडक्ट एनालिटिक्स", description: "SnapOtter को बेहतर बनाने में मदद के लिए गुमनाम उपयोग डेटा शेयर करें।", @@ -2974,6 +3021,19 @@ export const hi: TranslationKeys = { "आपके अकाउंट को इस एप्लिकेशन तक पहुंचने की अनुमति नहीं है। अपने एडमिनिस्ट्रेटर से संपर्क करें।", oidcUserLimitReached: "उपयोगकर्ता सीमा पहुंच गई। अपने एडमिनिस्ट्रेटर से संपर्क करें।", oidcGenericError: "ऑथेंटिकेशन त्रुटि। कृपया पुनः प्रयास करें।", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "पासवर्ड बदलाव आपके आइडेंटिटी प्रोवाइडर द्वारा प्रबंधित किए जाते हैं।", enterUsername: "यूज़रनेम दर्ज करें", enterPassword: "अपना पासवर्ड दर्ज करें", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index d2539146..8a53ab93 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -2719,6 +2719,33 @@ export const id: TranslationKeys = { currentPasswordIncorrect: "Kata sandi saat ini salah", changePasswordButton: "Ubah Kata Sandi", loginAttemptLimitNote: "Batas percobaan login dapat dikonfigurasi di Pengaturan Sistem.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Anggota", @@ -2800,6 +2827,12 @@ export const id: TranslationKeys = { nameRequired: "Nama tim wajib diisi", nameTooLong: "Nama tim maksimal 50 karakter", duplicateName: "Tim dengan nama ini sudah ada", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Peran", @@ -2881,6 +2914,10 @@ export const id: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "Kunci API", @@ -2925,6 +2962,16 @@ export const id: TranslationKeys = { startupCleanup: "Pembersihan Saat Startup", startupCleanupDescription: "Bersihkan file sementara lama saat server dimulai", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Analitik Produk", description: "Bagikan data penggunaan anonim untuk membantu meningkatkan SnapOtter.", @@ -2990,6 +3037,19 @@ export const id: TranslationKeys = { "Akun Anda tidak diizinkan mengakses aplikasi ini. Hubungi administrator Anda.", oidcUserLimitReached: "Batas pengguna tercapai. Hubungi administrator Anda.", oidcGenericError: "Kesalahan autentikasi. Silakan coba lagi.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Perubahan kata sandi dikelola oleh penyedia identitas Anda.", enterUsername: "Masukkan nama pengguna", enterPassword: "Masukkan kata sandi Anda", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index aa45ff26..dad1eaf9 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -2721,6 +2721,33 @@ export const it: TranslationKeys = { changePasswordButton: "Cambia password", loginAttemptLimitNote: "I limiti dei tentativi di accesso possono essere configurati nelle Impostazioni di sistema.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Persone", @@ -2803,6 +2830,12 @@ export const it: TranslationKeys = { nameRequired: "Il nome del team e obbligatorio", nameTooLong: "Il nome del team deve avere al massimo 50 caratteri", duplicateName: "Un team con questo nome esiste gia", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Ruoli", @@ -2885,6 +2918,10 @@ export const it: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "Chiavi API", @@ -2930,6 +2967,16 @@ export const it: TranslationKeys = { startupCleanup: "Pulizia all'avvio", startupCleanupDescription: "Pulisce i vecchi file temporanei all'avvio del server", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Analisi del prodotto", description: "Condividi dati di utilizzo anonimi per contribuire a migliorare SnapOtter.", @@ -2996,6 +3043,19 @@ export const it: TranslationKeys = { "Il tuo account non e autorizzato ad accedere a questa applicazione. Contatta l'amministratore.", oidcUserLimitReached: "Limite utenti raggiunto. Contatta l'amministratore.", oidcGenericError: "Errore di autenticazione. Riprova.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Le modifiche alla password sono gestite dal tuo provider di identita.", enterUsername: "Inserisci il nome utente", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index ed19ae77..fe9fb4da 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -2677,6 +2677,33 @@ export const ja: TranslationKeys = { currentPasswordIncorrect: "現在のパスワードが正しくありません", changePasswordButton: "パスワード変更", loginAttemptLimitNote: "ログイン試行制限はシステム設定で構成できます。", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "メンバー", @@ -2758,6 +2785,12 @@ export const ja: TranslationKeys = { nameRequired: "チーム名は必須です", nameTooLong: "チーム名は50文字以内にしてください", duplicateName: "同じ名前のチームが既にあります", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "ロール", @@ -2839,6 +2872,10 @@ export const ja: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "APIキー", @@ -2882,6 +2919,16 @@ export const ja: TranslationKeys = { startupCleanup: "起動時クリーンアップ", startupCleanupDescription: "サーバー起動時に古い一時ファイルをクリーンアップ", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "プロダクト分析", description: "匿名の使用データを共有してSnapOtterの改善にご協力ください。", @@ -2947,6 +2994,19 @@ export const ja: TranslationKeys = { "お使いのアカウントにはこのアプリケーションへのアクセス権がありません。管理者にお問い合わせください。", oidcUserLimitReached: "ユーザー上限に達しました。管理者にお問い合わせください。", oidcGenericError: "認証エラー。もう一度お試しください。", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "パスワードはIDプロバイダーで管理されています。", enterUsername: "ユーザー名を入力", enterPassword: "パスワードを入力", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 41e03227..7d9d0f21 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -2662,6 +2662,33 @@ export const ko: TranslationKeys = { currentPasswordIncorrect: "현재 비밀번호가 올바르지 않습니다", changePasswordButton: "비밀번호 변경", loginAttemptLimitNote: "로그인 시도 제한은 시스템 설정에서 구성할 수 있습니다.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "멤버", @@ -2742,6 +2769,12 @@ export const ko: TranslationKeys = { nameRequired: "팀 이름은 필수입니다", nameTooLong: "팀 이름은 50자 이내여야 합니다", duplicateName: "같은 이름의 팀이 이미 존재합니다", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "역할", @@ -2824,6 +2857,10 @@ export const ko: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API 키", @@ -2867,6 +2904,16 @@ export const ko: TranslationKeys = { startupCleanup: "시작 시 정리", startupCleanupDescription: "서버 시작 시 오래된 임시 파일 정리", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "제품 분석", description: "익명 사용 데이터를 공유하여 SnapOtter 개선에 도움을 주세요.", @@ -2932,6 +2979,19 @@ export const ko: TranslationKeys = { "귀하의 계정은 이 애플리케이션에 접근할 권한이 없습니다. 관리자에게 문의하세요.", oidcUserLimitReached: "사용자 한도에 도달했습니다. 관리자에게 문의하세요.", oidcGenericError: "인증 오류. 다시 시도해 주세요.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "비밀번호는 ID 제공자에서 관리됩니다.", enterUsername: "사용자명 입력", enterPassword: "비밀번호 입력", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 20c5735b..9a7a4fa8 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -2722,6 +2722,33 @@ export const nl: TranslationKeys = { currentPasswordIncorrect: "Huidig wachtwoord is onjuist", changePasswordButton: "Wachtwoord wijzigen", loginAttemptLimitNote: "Inlogpogingslimieten kun je instellen bij Systeeminstellingen.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Personen", @@ -2803,6 +2830,12 @@ export const nl: TranslationKeys = { nameRequired: "Teamnaam is verplicht", nameTooLong: "Teamnaam mag maximaal 50 tekens zijn", duplicateName: "Er bestaat al een team met deze naam", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Rollen", @@ -2885,6 +2918,10 @@ export const nl: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API-sleutels", @@ -2928,6 +2965,16 @@ export const nl: TranslationKeys = { startupCleanup: "Opruimen bij opstarten", startupCleanupDescription: "Oude tijdelijke bestanden opruimen wanneer de server start", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Productanalyse", description: "Deel anonieme gebruiksgegevens om SnapOtter te verbeteren.", @@ -2993,6 +3040,19 @@ export const nl: TranslationKeys = { "Je account heeft geen toegang tot deze applicatie. Neem contact op met je beheerder.", oidcUserLimitReached: "Gebruikerslimiet bereikt. Neem contact op met je beheerder.", oidcGenericError: "Authenticatiefout. Probeer het opnieuw.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Wachtwoordwijzigingen worden beheerd door je identiteitsprovider.", enterUsername: "Voer gebruikersnaam in", enterPassword: "Voer je wachtwoord in", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index ec7ca377..9cb0a478 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -2725,6 +2725,33 @@ export const pl: TranslationKeys = { changePasswordButton: "Zmień hasło", loginAttemptLimitNote: "Limity prób logowania można skonfigurować w Ustawieniach systemowych.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Użytkownicy", @@ -2807,6 +2834,12 @@ export const pl: TranslationKeys = { nameRequired: "Nazwa zespołu jest wymagana", nameTooLong: "Nazwa zespołu nie może przekraczać 50 znaków", duplicateName: "Zespół o takiej nazwie już istnieje", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Role", @@ -2889,6 +2922,10 @@ export const pl: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "Klucze API", @@ -2934,6 +2971,16 @@ export const pl: TranslationKeys = { startupCleanup: "Czyszczenie przy uruchomieniu", startupCleanupDescription: "Usuwanie starych plików tymczasowych przy uruchomieniu serwera", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Analityka", description: "Udostępnianie anonimowych danych o użytkowaniu w celu ulepszenia SnapOtter.", @@ -3000,6 +3047,19 @@ export const pl: TranslationKeys = { "Państwa konto nie ma uprawnień do korzystania z tej aplikacji. Skontaktuj się z administratorem.", oidcUserLimitReached: "Osiągnięto limit użytkowników. Skontaktuj się z administratorem.", oidcGenericError: "Błąd uwierzytelniania. Spróbuj ponownie.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Zarządzanie hasłem odbywa się przez dostawcę tożsamości.", enterUsername: "Wprowadź nazwę użytkownika", enterPassword: "Wprowadź hasło", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 67bae159..e15ab289 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -2721,6 +2721,33 @@ export const ptBR: TranslationKeys = { changePasswordButton: "Alterar senha", loginAttemptLimitNote: "Os limites de tentativas de login podem ser configurados nas Configuracoes do sistema.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Pessoas", @@ -2801,6 +2828,12 @@ export const ptBR: TranslationKeys = { nameRequired: "O nome da equipe e obrigatorio", nameTooLong: "O nome da equipe deve ter 50 caracteres ou menos", duplicateName: "Ja existe uma equipe com este nome", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Funcoes", @@ -2883,6 +2916,10 @@ export const ptBR: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "Chaves API", @@ -2927,6 +2964,16 @@ export const ptBR: TranslationKeys = { startupCleanup: "Limpeza na inicializacao", startupCleanupDescription: "Limpa arquivos temporarios antigos quando o servidor inicia", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Analitica do produto", description: "Compartilhe dados de uso anonimos para ajudar a melhorar o SnapOtter.", @@ -2993,6 +3040,19 @@ export const ptBR: TranslationKeys = { "Sua conta nao esta autorizada a acessar este aplicativo. Entre em contato com o administrador.", oidcUserLimitReached: "Limite de usuarios atingido. Entre em contato com o administrador.", oidcGenericError: "Erro de autenticacao. Tente novamente.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Alteracoes de senha sao gerenciadas pelo seu provedor de identidade.", enterUsername: "Digite seu nome de usuario", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 9f9711ee..02a6a374 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -2720,6 +2720,33 @@ export const ru: TranslationKeys = { currentPasswordIncorrect: "Текущий пароль неверен", changePasswordButton: "Изменить пароль", loginAttemptLimitNote: "Лимиты попыток входа можно настроить в Системных настройках.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Пользователи", @@ -2801,6 +2828,12 @@ export const ru: TranslationKeys = { nameRequired: "Название команды обязательно", nameTooLong: "Название команды не должно превышать 50 символов", duplicateName: "Команда с таким именем уже существует", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Роли", @@ -2882,6 +2915,10 @@ export const ru: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API-ключи", @@ -2927,6 +2964,16 @@ export const ru: TranslationKeys = { startupCleanup: "Очистка при запуске", startupCleanupDescription: "Удалять старые временные файлы при запуске сервера", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Аналитика", description: "Отправка анонимных данных об использовании для улучшения SnapOtter.", @@ -2992,6 +3039,19 @@ export const ru: TranslationKeys = { "Вашей учётной записи не предоставлен доступ к этому приложению. Обратитесь к администратору.", oidcUserLimitReached: "Достигнут лимит пользователей. Обратитесь к администратору.", oidcGenericError: "Ошибка аутентификации. Попробуйте снова.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Управление паролем осуществляется Вашим провайдером идентификации.", enterUsername: "Введите имя пользователя", enterPassword: "Введите пароль", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index 9179c0da..65d8c8a8 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -2717,6 +2717,33 @@ export const sv: TranslationKeys = { currentPasswordIncorrect: "Nuvarande losenord ar felaktigt", changePasswordButton: "Byt losenord", loginAttemptLimitNote: "Inloggningsforsaksgranser kan konfigureras i Systeminstallningar.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Personer", @@ -2799,6 +2826,12 @@ export const sv: TranslationKeys = { nameRequired: "Teamnamn kravs", nameTooLong: "Teamnamn far vara hogst 50 tecken", duplicateName: "Ett team med detta namn finns redan", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Roller", @@ -2880,6 +2913,10 @@ export const sv: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API-nycklar", @@ -2923,6 +2960,16 @@ export const sv: TranslationKeys = { startupCleanup: "Rensning vid start", startupCleanupDescription: "Rensa gamla temporara filer nar servern startar", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Produktanalys", description: "Dela anonym anvandningsdata for att forbaattra SnapOtter.", @@ -2988,6 +3035,19 @@ export const sv: TranslationKeys = { "Ditt konto har inte behorighet till denna applikation. Kontakta din administrator.", oidcUserLimitReached: "Anvandargrans nadd. Kontakta din administrator.", oidcGenericError: "Autentiseringsfel. Forsok igen.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Losenordsandringar hanteras av din identitetsleverantor.", enterUsername: "Ange anvandarnamn", enterPassword: "Ange ditt losenord", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index a32b4eb2..524ed216 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -2699,6 +2699,33 @@ export const th: TranslationKeys = { currentPasswordIncorrect: "รหัสผ่านปัจจุบันไม่ถูกต้อง", changePasswordButton: "เปลี่ยนรหัสผ่าน", loginAttemptLimitNote: "สามารถกำหนดจำนวนครั้งจำกัดการเข้าสู่ระบบได้ในตั้งค่าระบบ", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "สมาชิก", @@ -2779,6 +2806,12 @@ export const th: TranslationKeys = { nameRequired: "ต้องระบุชื่อทีม", nameTooLong: "ชื่อทีมต้องไม่เกิน 50 ตัวอักษร", duplicateName: "มีทีมชื่อนี้อยู่แล้ว", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "บทบาท", @@ -2860,6 +2893,10 @@ export const th: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "คีย์ API", @@ -2901,6 +2938,16 @@ export const th: TranslationKeys = { startupCleanup: "ล้างตอนเริ่มต้น", startupCleanupDescription: "ล้างไฟล์ชั่วคราวเก่าเมื่อเซิร์ฟเวอร์เริ่มทำงาน", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "การวิเคราะห์ผลิตภัณฑ์", description: "แบ่งปันข้อมูลการใช้งานแบบไม่ระบุตัวตนเพื่อช่วยปรับปรุง SnapOtter", @@ -2965,6 +3012,19 @@ export const th: TranslationKeys = { oidcUserNotAuthorized: "บัญชีของคุณไม่ได้รับอนุญาตให้เข้าถึงแอปพลิเคชันนี้ กรุณาติดต่อผู้ดูแลระบบ", oidcUserLimitReached: "ถึงจำนวนผู้ใช้สูงสุดแล้ว กรุณาติดต่อผู้ดูแลระบบ", oidcGenericError: "ข้อผิดพลาดในการยืนยันตัวตน กรุณาลองอีกครั้ง", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "การเปลี่ยนรหัสผ่านจัดการโดยผู้ให้บริการยืนยันตัวตนของคุณ", enterUsername: "กรอกชื่อผู้ใช้", enterPassword: "กรอกรหัสผ่านของคุณ", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 5c0e49f2..0068e71d 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -2723,6 +2723,33 @@ export const tr: TranslationKeys = { currentPasswordIncorrect: "Mevcut parola yanlış", changePasswordButton: "Parolayı Değiştir", loginAttemptLimitNote: "Giriş deneme limitleri Sistem Ayarlarından yapılandırılabilir.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Kişiler", @@ -2805,6 +2832,12 @@ export const tr: TranslationKeys = { nameRequired: "Takım adı gereklidir", nameTooLong: "Takım adı en fazla 50 karakter olmalıdır", duplicateName: "Bu isimde bir takım zaten var", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Roller", @@ -2886,6 +2919,10 @@ export const tr: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API Anahtarları", @@ -2930,6 +2967,16 @@ export const tr: TranslationKeys = { startupCleanup: "Başlangıç Temizliği", startupCleanupDescription: "Sunucu başladığında eski geçici dosyaları temizle", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Ürün Analitiği", description: @@ -2996,6 +3043,19 @@ export const tr: TranslationKeys = { "Hesabınız bu uygulamaya erişim yetkisine sahip değil. Yöneticinizle iletişime geçin.", oidcUserLimitReached: "Kullanıcı limitine ulaşıldı. Yöneticinizle iletişime geçin.", oidcGenericError: "Kimlik doğrulama hatası. Lütfen tekrar deneyin.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Parola değişiklikleri kimlik sağlayıcınız tarafından yönetilmektedir.", enterUsername: "Kullanıcı adını girin", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index def31d7e..b950725a 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -2720,6 +2720,33 @@ export const uk: TranslationKeys = { currentPasswordIncorrect: "Поточний пароль невірний", changePasswordButton: "Змінити пароль", loginAttemptLimitNote: "Ліміти спроб входу можна налаштувати в Системних налаштуваннях.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Користувачі", @@ -2801,6 +2828,12 @@ export const uk: TranslationKeys = { nameRequired: "Назва команди обов'язкова", nameTooLong: "Назва команди не може перевищувати 50 символів", duplicateName: "Команда з такою назвою вже існує", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Ролі", @@ -2882,6 +2915,10 @@ export const uk: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API-ключі", @@ -2927,6 +2964,16 @@ export const uk: TranslationKeys = { startupCleanup: "Очищення при запуску", startupCleanupDescription: "Видаляти старі тимчасові файли при запуску сервера", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Аналітика", description: "Надсилання анонімних даних про використання для поліпшення SnapOtter.", @@ -2993,6 +3040,19 @@ export const uk: TranslationKeys = { "Ваш обліковий запис не має доступу до цього застосунку. Зверніться до адміністратора.", oidcUserLimitReached: "Досягнуто ліміту користувачів. Зверніться до адміністратора.", oidcGenericError: "Помилка автентифікації. Спробуйте знову.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Керування паролем здійснюється Вашим постачальником ідентифікації.", enterUsername: "Введіть ім'я користувача", enterPassword: "Введіть пароль", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 3c78e855..d5332877 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -2719,6 +2719,33 @@ export const vi: TranslationKeys = { currentPasswordIncorrect: "Mật khẩu hiện tại không đúng", changePasswordButton: "Đổi mật khẩu", loginAttemptLimitNote: "Giới hạn đăng nhập có thể được cấu hình trong Cài đặt hệ thống.", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "Thành viên", @@ -2801,6 +2828,12 @@ export const vi: TranslationKeys = { nameRequired: "Tên nhóm là bắt buộc", nameTooLong: "Tên nhóm phải tối đa 50 ký tự", duplicateName: "Đã tồn tại nhóm có tên này", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "Vai trò", @@ -2882,6 +2915,10 @@ export const vi: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "Khóa API", @@ -2923,6 +2960,16 @@ export const vi: TranslationKeys = { startupCleanup: "Dọn dẹp khi khởi động", startupCleanupDescription: "Dọn dẹp tệp tạm cũ khi máy chủ khởi động", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "Phân tích sản phẩm", description: "Chia sẻ dữ liệu sử dụng ẩn danh để giúp cải thiện SnapOtter.", @@ -2988,6 +3035,19 @@ export const vi: TranslationKeys = { "Tài khoản của bạn không được phép truy cập ứng dụng này. Liên hệ quản trị viên.", oidcUserLimitReached: "Đã đạt giới hạn người dùng. Liên hệ quản trị viên.", oidcGenericError: "Lỗi xác thực. Vui lòng thử lại.", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "Việc đổi mật khẩu được quản lý bởi nhà cung cấp danh tính của bạn.", enterUsername: "Nhập tên đăng nhập", enterPassword: "Nhập mật khẩu của bạn", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 5d77cf8c..ee2bea00 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -2651,6 +2651,33 @@ export const zhCN: TranslationKeys = { currentPasswordIncorrect: "当前密码不正确", changePasswordButton: "修改密码", loginAttemptLimitNote: "登录尝试限制可在系统设置中配置。", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "成员", @@ -2731,6 +2758,12 @@ export const zhCN: TranslationKeys = { nameRequired: "团队名称为必填项", nameTooLong: "团队名称不能超过 50 个字符", duplicateName: "该名称的团队已存在", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "角色", @@ -2812,6 +2845,10 @@ export const zhCN: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API 密钥", @@ -2853,6 +2890,16 @@ export const zhCN: TranslationKeys = { startupCleanup: "启动时清理", startupCleanupDescription: "服务器启动时清理旧的临时文件", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "产品分析", description: "分享匿名使用数据,帮助改进 SnapOtter。", @@ -2917,6 +2964,19 @@ export const zhCN: TranslationKeys = { oidcUserNotAuthorized: "您的账号无权访问此应用。请联系管理员。", oidcUserLimitReached: "用户数量已达上限。请联系管理员。", oidcGenericError: "认证错误。请重试。", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "密码修改由您的身份提供商管理。", enterUsername: "输入用户名", enterPassword: "输入密码", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 94c047f2..0714ee84 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -2649,6 +2649,33 @@ export const zhTW: TranslationKeys = { currentPasswordIncorrect: "目前密碼不正確", changePasswordButton: "變更密碼", loginAttemptLimitNote: "登入嘗試限制可在系統設定中配置。", + adminHeading: "Admin Security Settings", + adminDescription: "Enterprise security policy settings. These apply to all users.", + sessionIdleTimeout: "Session Idle Timeout (minutes)", + sessionIdleTimeoutDesc: "Automatically log out idle sessions. 0 = disabled.", + maxSessionsPerUser: "Max Sessions Per User", + maxSessionsPerUserDesc: "Limit concurrent sessions per user. 0 = unlimited.", + mfaPolicy: "MFA Policy", + mfaPolicyDesc: "Require multi-factor authentication for users.", + mfaPolicyOptional: "Optional", + mfaPolicyAdminsOnly: "Required for admins", + mfaPolicyRequired: "Required for all users", + ssoEnforcement: "SSO Enforcement", + ssoEnforcementDesc: "Require SSO login for all non-break-glass users.", + ssoBreakGlassUsername: "Break Glass Admin Username", + ssoBreakGlassUsernameDesc: + "This admin can still log in with local credentials when SSO is enforced.", + passwordMinLength: "Minimum Password Length", + passwordMinLengthDesc: "Minimum number of characters required for passwords.", + passwordRequireUppercase: "Require Uppercase", + passwordRequireUppercaseDesc: "Require at least one uppercase letter.", + passwordRequireNumber: "Require Number", + passwordRequireNumberDesc: "Require at least one number.", + passwordRequireSpecial: "Require Special Character", + passwordRequireSpecialDesc: "Require at least one special character.", + passwordPolicyHeading: "Password Policy", + securitySettingsSaved: "Security settings saved", + securitySettingsFailed: "Failed to save security settings", }, people: { heading: "成員", @@ -2729,6 +2756,12 @@ export const zhTW: TranslationKeys = { nameRequired: "需要團隊名稱", nameTooLong: "團隊名稱不超過50個字元", duplicateName: "已存在同名團隊", + teamStorageQuota: "Storage Quota (MB)", + teamStorageQuotaDesc: "Maximum storage for this team in MB. Leave empty for no limit.", + teamRetentionHours: "Retention (hours)", + teamRetentionHoursDesc: + "Processing file retention for this team. Leave empty to use global default.", + quotaSaved: "Team settings saved", }, roles: { heading: "角色", @@ -2810,6 +2843,10 @@ export const zhTW: TranslationKeys = { storageHeading: "Library storage", storageFiles: "{count} files", unknownUser: "(unknown)", + storageByTeam: "Storage by Team", + teamColumn: "Team", + storageColumn: "Storage", + usersColumn: "Users", }, apiKeys: { heading: "API金鑰", @@ -2851,6 +2888,16 @@ export const zhTW: TranslationKeys = { startupCleanup: "啟動清理", startupCleanupDescription: "伺服器啟動時清理舊的暫存檔案", }, + dataRetention: { + title: "Data Retention", + fileMaxAgeHours: "Processing file retention (hours)", + fileMaxAgeHoursDesc: "How long to keep uploaded and processed files. Default: 72 hours.", + jobsRetentionDays: "Job record retention (days)", + jobsRetentionDaysDesc: "How long to keep job metadata. 0 = keep forever. Default: 30 days.", + auditRetentionDays: "Audit log retention (days)", + auditRetentionDaysDesc: + "How long to keep audit log entries. 0 = keep forever. Default: forever.", + }, analytics: { heading: "產品分析", description: "分享匿名使用資料以協助改進SnapOtter。", @@ -2915,6 +2962,19 @@ export const zhTW: TranslationKeys = { oidcUserNotAuthorized: "您的帳戶無權存取此應用程式。請聯絡管理員。", oidcUserLimitReached: "已達使用者上限,請聯絡管理員。", oidcGenericError: "驗證錯誤,請重試。", + samlAuthFailed: "SAML authentication failed. Please try again.", + samlUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + samlUserLimitReached: "User limit reached. Contact your administrator.", + ssoEnforcedLocalRestricted: "Local login is restricted to authorized administrators.", + mfaRequired: "Enter your authentication code", + mfaRecoveryHint: "You can also use a recovery code", + verify: "Verify", + verifying: "Verifying...", + mfaInvalidCode: "Invalid code. Please try again.", + mfaEnrollmentRequired: + "Your organization requires multi-factor authentication. Please set up MFA in your account settings.", + methodSaml: "SAML", passwordManagedByProvider: "密碼由您的身分提供者管理。", enterUsername: "輸入使用者名稱", enterPassword: "輸入密碼", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c3d895dd..e5b0f925 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ export * from "./analytics/consent.js"; export * from "./analytics/events.js"; export * from "./analytics/types.js"; +export * from "./audit-events.js"; export * from "./constants.js"; export * from "./features.js"; export * from "./i18n/index.js"; diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index 0394cd08..be89d88f 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -12,6 +12,9 @@ export type Permission = | "teams:manage" | "features:manage" | "system:health" - | "audit:read"; + | "audit:read" + | "compliance:manage" + | "webhooks:manage" + | "security:manage"; export type Role = "admin" | "editor" | "user"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af5fc750..80e2cb5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 2.4.16 '@fast-check/vitest': specifier: ^0.4.1 - version: 0.4.1(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)) + version: 0.4.1(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)) '@playwright/test': specifier: ^1.60.0 version: 1.60.0 @@ -85,7 +85,7 @@ importers: version: 0.5.8 '@vitest/coverage-v8': specifier: ^3.2.6 - version: 3.2.6(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)) + version: 3.2.6(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)) adm-zip: specifier: ^0.5.17 version: 0.5.17 @@ -97,7 +97,7 @@ importers: version: 9.1.7 jsdom: specifier: ^29.1.1 - version: 29.1.1(@noble/hashes@1.8.0) + version: 29.1.1(@noble/hashes@2.2.0) lint-staged: specifier: ^16.4.0 version: 16.4.0 @@ -112,7 +112,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.6 - version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) zod-fast-check: specifier: ^0.10.1 version: 0.10.1(fast-check@4.8.0)(zod@4.3.6) @@ -137,6 +137,9 @@ importers: '@neplex/vectorizer': specifier: ^0.1.0 version: 0.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@node-saml/node-saml': + specifier: ^5.1.0 + version: 5.1.0 '@scalar/fastify-api-reference': specifier: ^1.57.5 version: 1.58.0 @@ -209,6 +212,9 @@ importers: opentype.js: specifier: ^2.0.0 version: 2.0.0 + otpauth: + specifier: ^9.5.1 + version: 9.5.1 p-queue: specifier: ^9.3.0 version: 9.3.0 @@ -349,22 +355,34 @@ importers: apps/landing: dependencies: - '@astrojs/sitemap': - specifier: ^3.3.0 - version: 3.7.3 '@snapotter/shared': specifier: workspace:* version: link:../../packages/shared - astro: - specifier: ^5.8.0 - version: 5.18.2(@types/node@25.8.0)(ioredis@5.10.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.8.3) + framer-motion: + specifier: ^11.18.0 + version: 11.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + lucide-react: + specifier: ^0.577.0 + version: 0.577.0(react@19.2.7) + next: + specifier: ^15.5.18 + version: 15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) devDependencies: - '@astrojs/check': - specifier: ^0.9.0 - version: 0.9.9(prettier@3.8.4)(typescript@5.9.3) - '@tailwindcss/vite': + '@tailwindcss/postcss': specifier: ^4.3.0 - version: 4.3.0(vite@8.0.16(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)) + version: 4.3.1 + '@types/react': + specifier: ^19.2.16 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.16) tailwindcss: specifier: ^4.3.0 version: 4.3.0 @@ -514,7 +532,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.6 - version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) packages/enterprise: dependencies: @@ -549,13 +567,13 @@ importers: version: 9.6.1(@types/node@25.8.0) '@stryker-mutator/vitest-runner': specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.8.0))(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)) + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.8.0))(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^3.2.6 - version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) packages/media-engine: dependencies: @@ -568,7 +586,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.6 - version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) packages/shared: devDependencies: @@ -669,6 +687,10 @@ packages: resolution: {integrity: sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA==} engines: {node: '>= 14.0.0'} + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -688,47 +710,6 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@astrojs/check@0.9.9': - resolution: {integrity: sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==} - hasBin: true - peerDependencies: - typescript: ^5.0.0 || ^6.0.0 - - '@astrojs/compiler@2.13.1': - resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} - - '@astrojs/internal-helpers@0.7.6': - resolution: {integrity: sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==} - - '@astrojs/language-server@2.16.10': - resolution: {integrity: sha512-87VQ/5GSdHlRnUA+hGuerYyIGAj+9RbZmATyuKLEUePinUXhQ5YkRnRrHhOD9sSi5JOErLjrLkHnfZFEvGrV8w==} - hasBin: true - peerDependencies: - prettier: ^3.0.0 - prettier-plugin-astro: '>=0.11.0' - peerDependenciesMeta: - prettier: - optional: true - prettier-plugin-astro: - optional: true - - '@astrojs/markdown-remark@6.3.11': - resolution: {integrity: sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==} - - '@astrojs/prism@3.3.0': - resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} - - '@astrojs/sitemap@3.7.3': - resolution: {integrity: sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==} - - '@astrojs/telemetry@3.3.0': - resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} - - '@astrojs/yaml2ts@0.2.4': - resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} - '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1130,10 +1111,6 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true - '@capsizecss/unpack@4.0.1': - resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} - engines: {node: '>=18'} - '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -1222,27 +1199,6 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@emmetio/abbreviation@2.3.3': - resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} - - '@emmetio/css-abbreviation@2.1.8': - resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} - - '@emmetio/css-parser@0.4.1': - resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} - - '@emmetio/html-matcher@1.3.0': - resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} - - '@emmetio/scanner@1.0.4': - resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} - - '@emmetio/stream-reader-utils@0.1.0': - resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} - - '@emmetio/stream-reader@2.2.0': - resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} - '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1269,12 +1225,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.0': resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} engines: {node: '>=18'} @@ -1293,12 +1243,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.0': resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} engines: {node: '>=18'} @@ -1317,12 +1261,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.0': resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} engines: {node: '>=18'} @@ -1341,12 +1279,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.0': resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} engines: {node: '>=18'} @@ -1365,12 +1297,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.0': resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} engines: {node: '>=18'} @@ -1389,12 +1315,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.0': resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} engines: {node: '>=18'} @@ -1413,12 +1333,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.0': resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} engines: {node: '>=18'} @@ -1437,12 +1351,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} engines: {node: '>=18'} @@ -1461,12 +1369,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.0': resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} engines: {node: '>=18'} @@ -1485,12 +1387,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.0': resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} engines: {node: '>=18'} @@ -1509,12 +1405,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.0': resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} engines: {node: '>=18'} @@ -1533,12 +1423,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.0': resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} engines: {node: '>=18'} @@ -1557,12 +1441,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.0': resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} engines: {node: '>=18'} @@ -1581,12 +1459,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.0': resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} engines: {node: '>=18'} @@ -1605,12 +1477,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.0': resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} engines: {node: '>=18'} @@ -1629,12 +1495,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.0': resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} engines: {node: '>=18'} @@ -1653,12 +1513,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.0': resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} engines: {node: '>=18'} @@ -1671,12 +1525,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.0': resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} engines: {node: '>=18'} @@ -1695,12 +1543,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} engines: {node: '>=18'} @@ -1713,12 +1555,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.0': resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} engines: {node: '>=18'} @@ -1737,12 +1573,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} engines: {node: '>=18'} @@ -1755,12 +1585,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.0': resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} engines: {node: '>=18'} @@ -1779,12 +1603,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.0': resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} engines: {node: '>=18'} @@ -1803,12 +1621,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.0': resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} engines: {node: '>=18'} @@ -1827,12 +1639,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.0': resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} engines: {node: '>=18'} @@ -1851,12 +1657,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.0': resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} engines: {node: '>=18'} @@ -2787,6 +2587,57 @@ packages: engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} hasBin: true + '@next/env@15.5.19': + resolution: {integrity: sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==} + + '@next/swc-darwin-arm64@15.5.19': + resolution: {integrity: sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.19': + resolution: {integrity: sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.19': + resolution: {integrity: sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.5.19': + resolution: {integrity: sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.5.19': + resolution: {integrity: sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.5.19': + resolution: {integrity: sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.5.19': + resolution: {integrity: sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.19': + resolution: {integrity: sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -2795,9 +2646,17 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@2.1.1': resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + '@node-saml/node-saml@5.1.0': + resolution: {integrity: sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==} + engines: {node: '>= 18'} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -2882,9 +2741,6 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} - '@oslojs/encoding@1.1.0': - resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} - '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -3016,15 +2872,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/pluginutils@5.4.0': - resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@scalar/client-side-rendering@0.1.13': resolution: {integrity: sha512-p8V4HgEWjaCpqsnhclg1pTfjE9JA0AWRr0ocBQHexoHo+pqnSs1d83Mv9rjH7R0FZJrlCSandZZeY3DMX2gYXQ==} engines: {node: '>=22'} @@ -3192,42 +3039,24 @@ packages: '@shikijs/core@2.5.0': resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} - '@shikijs/core@3.23.0': - resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} - '@shikijs/engine-javascript@2.5.0': resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} - '@shikijs/engine-javascript@3.23.0': - resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} - '@shikijs/engine-oniguruma@2.5.0': resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} - '@shikijs/engine-oniguruma@3.23.0': - resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} - '@shikijs/langs@2.5.0': resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} - '@shikijs/langs@3.23.0': - resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} - '@shikijs/themes@2.5.0': resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} - '@shikijs/themes@3.23.0': - resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} - '@shikijs/transformers@2.5.0': resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} '@shikijs/types@2.5.0': resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} - '@shikijs/types@3.23.0': - resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} - '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -3302,66 +3131,126 @@ packages: '@stryker-mutator/core': 9.6.1 vitest: '>=2.0.0' + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.19': resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + '@tailwindcss/node@4.3.1': + resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} + '@tailwindcss/oxide-android-arm64@4.3.0': resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} engines: {node: '>= 20'} cpu: [arm64] os: [android] + '@tailwindcss/oxide-android-arm64@4.3.1': + resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + '@tailwindcss/oxide-darwin-arm64@4.3.0': resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] + '@tailwindcss/oxide-darwin-arm64@4.3.1': + resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.3.0': resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.3.1': + resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + '@tailwindcss/oxide-freebsd-x64@4.3.0': resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] + '@tailwindcss/oxide-freebsd-x64@4.3.1': + resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} engines: {node: '>=14.0.0'} @@ -3374,22 +3263,53 @@ packages: - '@emnapi/wasi-threads' - tslib + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + '@tailwindcss/oxide@4.3.0': resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} engines: {node: '>= 20'} + '@tailwindcss/oxide@4.3.1': + resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.1': + resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + '@tailwindcss/vite@4.3.0': resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} peerDependencies: @@ -3538,9 +3458,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/nlcst@2.0.3': - resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} - '@types/node@14.18.63': resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} @@ -3553,9 +3470,6 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} - '@types/node@24.13.2': - resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} - '@types/node@25.8.0': resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} @@ -3583,6 +3497,9 @@ packages: '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3607,9 +3524,6 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} - '@types/sax@1.2.7': - resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} - '@types/ssh2-streams@0.1.13': resolution: {integrity: sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==} @@ -3632,6 +3546,12 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/xml-encryption@1.2.4': + resolution: {integrity: sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==} + + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@types/yauzl@3.3.0': resolution: {integrity: sha512-7YJZZveDGYqCMt0PrN0sjZj9gT07xLqnHBimKwhWYTJ4lh5MxDGmaCaN4+6x1TOPt+dk3Ge8w9cO3uJXoy/qrA==} @@ -3698,32 +3618,6 @@ packages: '@vitest/utils@3.2.6': resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} - '@volar/kit@2.4.28': - resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} - peerDependencies: - typescript: '*' - - '@volar/language-core@2.4.28': - resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} - - '@volar/language-server@2.4.28': - resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} - - '@volar/language-service@2.4.28': - resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} - - '@volar/source-map@2.4.28': - resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} - - '@volar/typescript@2.4.28': - resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} - - '@vscode/emmet-helper@2.11.0': - resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} - - '@vscode/l10n@0.0.18': - resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} - '@vue/compiler-core@3.5.30': resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} @@ -3812,6 +3706,14 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@xmldom/is-dom-node@1.0.1': + resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} + engines: {node: '>= 16'} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -3872,9 +3774,6 @@ packages: resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} engines: {node: '>= 14'} - ansi-align@3.0.1: - resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} - ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -3909,10 +3808,6 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - archiver-utils@2.1.0: resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} engines: {node: '>= 6'} @@ -3933,9 +3828,6 @@ packages: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -3955,9 +3847,6 @@ packages: array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} - array-iterate@2.0.1: - resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} - asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -3968,11 +3857,6 @@ packages: ast-v8-to-istanbul@0.3.12: resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} - astro@5.18.2: - resolution: {integrity: sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ==} - engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} - hasBin: true - async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} @@ -3986,10 +3870,6 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - b4a@1.8.0: resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} peerDependencies: @@ -4046,9 +3926,6 @@ packages: bare-url@2.4.0: resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==} - base-64@1.0.0: - resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} - base64-js@0.0.8: resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} engines: {node: '>= 0.4'} @@ -4098,19 +3975,12 @@ packages: bmp-js@0.1.0: resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - boxen@8.0.1: - resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} - engines: {node: '>=18'} - brace-expansion@2.1.1: resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} @@ -4202,10 +4072,6 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - camelcase@8.0.0: - resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} - engines: {node: '>=16'} - caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} @@ -4254,14 +4120,6 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -4269,10 +4127,6 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} - engines: {node: '>=8'} - cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} @@ -4284,10 +4138,6 @@ packages: resolution: {integrity: sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==} engines: {node: '>=14.16'} - cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -4309,6 +4159,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -4354,10 +4207,6 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -4365,9 +4214,6 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - common-ancestor-path@1.0.1: - resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} - compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -4424,9 +4270,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-es@1.2.3: - resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} - cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -4475,40 +4318,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - crossws@0.3.5: - resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} - crypto-random-string@4.0.0: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} - - css-tree@2.2.1: - resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - csso@5.0.5: - resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -4553,9 +4373,6 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} - defu@6.1.7: - resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -4571,20 +4388,10 @@ packages: des.js@1.1.0: resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - deterministic-object-hash@2.0.2: - resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} - engines: {node: '>=18'} - - devalue@5.8.1: - resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} - devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -4594,10 +4401,6 @@ packages: diff-match-patch@1.0.5: resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} - diff@8.0.4: - resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} - engines: {node: '>=0.3.1'} - dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -4605,9 +4408,6 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - docker-compose@1.4.2: resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} engines: {node: '>= 6.0.0'} @@ -4626,25 +4426,12 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - dom-walk@0.1.2: resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - dompurify@3.4.1: resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==} - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -4749,10 +4536,6 @@ packages: sqlite3: optional: true - dset@3.1.4: - resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} - engines: {node: '>=4'} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -4766,9 +4549,6 @@ packages: electron-to-chromium@1.5.321: resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} - emmet@2.4.11: - resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} - emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -4791,14 +4571,14 @@ packages: resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -4851,11 +4631,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} @@ -5060,10 +4835,6 @@ packages: resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} engines: {node: '>=18'} - flattie@1.1.1: - resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} - engines: {node: '>=8'} - focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} @@ -5076,16 +4847,9 @@ packages: debug: optional: true - fontace@0.4.1: - resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} - fontkit@2.0.4: resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} - fontkitten@1.0.3: - resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} - engines: {node: '>=20'} - foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -5094,6 +4858,20 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + framer-motion@11.18.2: + resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -5209,9 +4987,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - h3@1.15.11: - resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} - handlebars@4.7.9: resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} @@ -5233,36 +5008,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hast-util-from-html@2.0.3: - resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} - - hast-util-from-parse5@8.0.3: - resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} - - hast-util-is-element@3.0.0: - resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} - - hast-util-parse-selector@4.0.0: - resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} - - hast-util-raw@9.1.0: - resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} - hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - hast-util-to-parse5@8.0.1: - resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} - - hast-util-to-text@4.0.2: - resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} - hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - hastscript@9.0.1: - resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -5288,15 +5039,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-escaper@3.0.3: - resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} - html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -5380,17 +5125,9 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} - iron-webcrypto@1.2.1: - resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} - is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -5406,11 +5143,6 @@ packages: is-function@1.0.2: resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==} - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -5446,10 +5178,6 @@ packages: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} - is-wsl@3.1.1: - resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} - engines: {node: '>=16'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -5568,12 +5296,6 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-parser@2.3.1: - resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} - - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} @@ -5588,14 +5310,6 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - konva@10.3.0: resolution: {integrity: sha512-gt19K2gzY4lHbnkvsku7eSmB+A9PTS2jG4F9coBMsdjM1UKfJNxJbDbXVpeCW1wjEGRwBD3nBamcHnqJhAeKlg==} @@ -5829,9 +5543,6 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} - make-asynchronous@1.1.0: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} engines: {node: '>=18'} @@ -5847,9 +5558,6 @@ packages: resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} hasBin: true - markdown-table@3.0.4: - resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - markdown-title@1.0.2: resolution: {integrity: sha512-MqIQVVkz+uGEHi3TsHx/czcxxCbRIL7sv5K5DnYw/tI+apY54IbPefV/cmgxp6LoJSEx/TqcHdLs/298afG5QQ==} engines: {node: '>=6'} @@ -5869,36 +5577,12 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdast-util-definitions@6.0.0: - resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} - - mdast-util-find-and-replace@3.0.2: - resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} mdast-util-frontmatter@2.0.1: resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} - mdast-util-gfm-autolink-literal@2.0.1: - resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} - - mdast-util-gfm-footnote@2.1.0: - resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} - - mdast-util-gfm-strikethrough@2.0.0: - resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} - - mdast-util-gfm-table@2.0.0: - resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} - - mdast-util-gfm-task-list-item@2.0.0: - resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} - - mdast-util-gfm@3.1.0: - resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} - mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -5911,9 +5595,6 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mdn-data@2.0.28: - resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} - mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -5933,27 +5614,6 @@ packages: micromark-extension-frontmatter@2.0.0: resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} - micromark-extension-gfm-autolink-literal@2.1.0: - resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} - - micromark-extension-gfm-footnote@2.1.0: - resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} - - micromark-extension-gfm-strikethrough@2.1.0: - resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} - - micromark-extension-gfm-table@2.1.1: - resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} - - micromark-extension-gfm-tagfilter@2.0.0: - resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} - - micromark-extension-gfm-task-list-item@2.1.0: - resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} - - micromark-extension-gfm@3.0.0: - resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} - micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -6103,9 +5763,11 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} + motion-dom@11.18.1: + resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} + + motion-utils@11.18.1: + resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -6117,9 +5779,6 @@ packages: msgpackr@2.0.2: resolution: {integrity: sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==} - muggle-string@0.4.1: - resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - mupdf@1.27.0: resolution: {integrity: sha512-vEPUYwZeu5NgiFLz4e20R7Vp2pNY7szirGEvTxHyQQpQs6ab4DeGdonwT6sH1JZG5EhyHSrojZrZn2/0ee6qZQ==} @@ -6162,15 +5821,29 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - neotraverse@0.6.18: - resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} - engines: {node: '>= 10'} - nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} - nlcst-to-string@4.0.0: - resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + next@15.5.19: + resolution: {integrity: sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true node-abi@3.89.0: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} @@ -6183,16 +5856,10 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} - node-fetch-native@1.6.7: - resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true - node-mock-http@1.0.4: - resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} - node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -6295,9 +5962,6 @@ packages: - validate-npm-package-name - which - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - oauth4webapi@3.8.6: resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} @@ -6309,12 +5973,6 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - ofetch@1.5.1: - resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} - - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - omggif@1.0.10: resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} @@ -6337,15 +5995,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} - oniguruma-parser@0.12.2: - resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} - oniguruma-to-es@3.1.1: resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} - oniguruma-to-es@4.3.6: - resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - openid-client@6.8.4: resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==} @@ -6353,6 +6005,9 @@ packages: resolution: {integrity: sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==} hasBin: true + otpauth@9.5.1: + resolution: {integrity: sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==} + p-each-series@3.0.0: resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==} engines: {node: '>=12'} @@ -6373,10 +6028,6 @@ packages: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} - p-limit@6.2.0: - resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} - engines: {node: '>=18'} - p-locate@2.0.0: resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} engines: {node: '>=4'} @@ -6389,10 +6040,6 @@ packages: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} - p-queue@8.1.1: - resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} - engines: {node: '>=18'} - p-queue@9.3.0: resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} engines: {node: '>=20'} @@ -6424,9 +6071,6 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} @@ -6464,9 +6108,6 @@ packages: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} - parse-latin@7.0.0: - resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} - parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -6480,15 +6121,9 @@ packages: parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} - path-browserify@1.0.1: - resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -6587,9 +6222,6 @@ packages: engines: {node: '>= 8'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - piccolore@0.1.3: - resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6687,11 +6319,6 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true - prettier@3.8.4: - resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} - engines: {node: '>=14'} - hasBin: true - pretty-bytes@7.1.0: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} @@ -6704,10 +6331,6 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - prismjs@1.30.0: - resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} - engines: {node: '>=6'} - process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -6729,10 +6352,6 @@ packages: resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} engines: {node: ^16 || ^18 || >=20} - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -6786,9 +6405,6 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - radix3@1.1.2: - resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} - rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -6899,14 +6515,6 @@ packages: readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} - engines: {node: '>= 20.19.0'} - real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -6939,46 +6547,18 @@ packages: resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} engines: {node: '>=14'} - rehype-parse@9.0.1: - resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} - - rehype-raw@7.0.0: - resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} - - rehype-stringify@10.0.1: - resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} - - rehype@13.0.2: - resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} - remark-frontmatter@5.0.0: resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} - remark-gfm@4.0.1: - resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} - remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - remark-rehype@11.1.2: - resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} - - remark-smartypants@3.0.2: - resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} - engines: {node: '>=16.0.0'} - remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} - request-light@0.5.8: - resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} - - request-light@0.7.0: - resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -7016,18 +6596,6 @@ packages: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} - retext-latin@4.0.0: - resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} - - retext-smartypants@6.2.0: - resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} - - retext-stringify@4.0.0: - resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} - - retext@9.0.0: - resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} - retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -7156,9 +6724,6 @@ packages: shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} - shiki@3.23.0: - resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -7195,14 +6760,6 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - sitemap@9.0.1: - resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==} - engines: {node: '>=20.19.5', npm: '>=10.8.2'} - hasBin: true - skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -7215,10 +6772,6 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} - smol-toml@1.6.1: - resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} - engines: {node: '>= 18'} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -7304,9 +6857,6 @@ packages: stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} - stream-replace-string@2.0.0: - resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} - streamx@2.25.0: resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} @@ -7385,6 +6935,19 @@ packages: resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} engines: {node: '>=10'} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + super-regex@1.1.0: resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} engines: {node: '>=18'} @@ -7405,11 +6968,6 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} - svgo@4.0.1: - resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} - engines: {node: '>=16'} - hasBin: true - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -7426,6 +6984,9 @@ packages: tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + tailwindcss@4.3.1: + resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -7588,16 +7149,6 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - tsconfck@3.1.6: - resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} - engines: {node: ^18 || >=20} - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -7648,12 +7199,6 @@ packages: resolution: {integrity: sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==} engines: {node: '>= 16.0.0'} - typesafe-path@0.2.2: - resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} - - typescript-auto-import-cache@0.3.6: - resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -7662,20 +7207,11 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.6.4: - resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} hasBin: true - ultrahtml@1.6.0: - resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} - - uncrypto@0.1.3: - resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} @@ -7685,9 +7221,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -7724,37 +7257,22 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - unifont@0.7.4: - resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} - unique-string@3.0.0: resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} engines: {node: '>=12'} - unist-util-find-after@5.0.0: - resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} - unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - unist-util-modify-children@4.0.0: - resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} - unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - unist-util-remove-position@5.0.0: - resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} - unist-util-remove@4.0.0: resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==} unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - unist-util-visit-children@3.0.0: - resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} - unist-util-visit-parents@6.0.2: resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} @@ -7768,68 +7286,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unstorage@1.17.5: - resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} - peerDependencies: - '@azure/app-configuration': ^1.8.0 - '@azure/cosmos': ^4.2.0 - '@azure/data-tables': ^13.3.0 - '@azure/identity': ^4.6.0 - '@azure/keyvault-secrets': ^4.9.0 - '@azure/storage-blob': ^12.26.0 - '@capacitor/preferences': ^6 || ^7 || ^8 - '@deno/kv': '>=0.9.0' - '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 - '@planetscale/database': ^1.19.0 - '@upstash/redis': ^1.34.3 - '@vercel/blob': '>=0.27.1' - '@vercel/functions': ^2.2.12 || ^3.0.0 - '@vercel/kv': ^1 || ^2 || ^3 - aws4fetch: ^1.0.20 - db0: '>=0.2.1' - idb-keyval: ^6.2.1 - ioredis: ^5.4.2 - uploadthing: ^7.4.4 - peerDependenciesMeta: - '@azure/app-configuration': - optional: true - '@azure/cosmos': - optional: true - '@azure/data-tables': - optional: true - '@azure/identity': - optional: true - '@azure/keyvault-secrets': - optional: true - '@azure/storage-blob': - optional: true - '@capacitor/preferences': - optional: true - '@deno/kv': - optional: true - '@netlify/blobs': - optional: true - '@planetscale/database': - optional: true - '@upstash/redis': - optional: true - '@vercel/blob': - optional: true - '@vercel/functions': - optional: true - '@vercel/kv': - optional: true - aws4fetch: - optional: true - db0: - optional: true - idb-keyval: - optional: true - ioredis: - optional: true - uploadthing: - optional: true - unzipper@0.10.14: resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} @@ -7863,9 +7319,6 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - vfile-location@5.0.3: - resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} - vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -7920,14 +7373,6 @@ packages: yaml: optional: true - vitefu@1.1.3: - resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} - peerDependencies: - vite: '>=6.4.2' - peerDependenciesMeta: - vite: - optional: true - vitepress-plugin-llms@1.13.1: resolution: {integrity: sha512-m+rxyghF5INi8hBw0huFPx6+VvaX1tDGvw1H7FdXowaZJ3dcRY5ShgbmK1AQlmeOFMdd16H8WarhSHLPXF/2OA==} engines: {node: '>=18'} @@ -7972,108 +7417,6 @@ packages: jsdom: optional: true - volar-service-css@0.0.70: - resolution: {integrity: sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==} - peerDependencies: - '@volar/language-service': ~2.4.0 - peerDependenciesMeta: - '@volar/language-service': - optional: true - - volar-service-emmet@0.0.70: - resolution: {integrity: sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==} - peerDependencies: - '@volar/language-service': ~2.4.0 - peerDependenciesMeta: - '@volar/language-service': - optional: true - - volar-service-html@0.0.70: - resolution: {integrity: sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==} - peerDependencies: - '@volar/language-service': ~2.4.0 - peerDependenciesMeta: - '@volar/language-service': - optional: true - - volar-service-prettier@0.0.70: - resolution: {integrity: sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==} - peerDependencies: - '@volar/language-service': ~2.4.0 - prettier: ^2.2 || ^3.0 - peerDependenciesMeta: - '@volar/language-service': - optional: true - prettier: - optional: true - - volar-service-typescript-twoslash-queries@0.0.70: - resolution: {integrity: sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==} - peerDependencies: - '@volar/language-service': ~2.4.0 - peerDependenciesMeta: - '@volar/language-service': - optional: true - - volar-service-typescript@0.0.70: - resolution: {integrity: sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==} - peerDependencies: - '@volar/language-service': ~2.4.0 - peerDependenciesMeta: - '@volar/language-service': - optional: true - - volar-service-yaml@0.0.70: - resolution: {integrity: sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==} - peerDependencies: - '@volar/language-service': ~2.4.0 - peerDependenciesMeta: - '@volar/language-service': - optional: true - - vscode-css-languageservice@6.3.10: - resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} - - vscode-html-languageservice@5.6.2: - resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} - - vscode-json-languageservice@4.1.8: - resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} - engines: {npm: '>=7.0.0'} - - vscode-jsonrpc@8.2.0: - resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} - engines: {node: '>=14.0.0'} - - vscode-jsonrpc@9.0.0: - resolution: {integrity: sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg==} - engines: {node: '>=14.0.0'} - - vscode-languageserver-protocol@3.17.5: - resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} - - vscode-languageserver-protocol@3.18.0: - resolution: {integrity: sha512-Zdz+kJ12Iz6tc11xfZyEo501bBATHXrCjmMfnaR3pMnf1CoqZBKIynba3P+/bi9VEdrMbNtAVKYpKhbODvqy+Q==} - - vscode-languageserver-textdocument@1.0.12: - resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} - - vscode-languageserver-types@3.17.5: - resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} - - vscode-languageserver-types@3.18.0: - resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} - - vscode-languageserver@9.0.1: - resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} - hasBin: true - - vscode-nls@5.2.0: - resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} - - vscode-uri@3.1.0: - resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} - vue@3.5.30: resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} peerDependencies: @@ -8089,9 +7432,6 @@ packages: weapon-regex@1.3.6: resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==} - web-namespaces@2.0.1: - resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - web-vitals@5.2.0: resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} @@ -8113,10 +7453,6 @@ packages: which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - which-pm-runs@1.1.0: - resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} - engines: {node: '>=4'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -8132,10 +7468,6 @@ packages: engines: {node: '>=8'} hasBin: true - widest-line@5.0.0: - resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} - engines: {node: '>=18'} - wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -8161,6 +7493,13 @@ packages: xhr@2.6.0: resolution: {integrity: sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==} + xml-crypto@6.1.2: + resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==} + engines: {node: '>=16'} + + xml-encryption@3.1.0: + resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -8176,20 +7515,37 @@ packages: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xpath@0.0.32: + resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==} + engines: {node: '>=0.6.0'} + + xpath@0.0.33: + resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==} + engines: {node: '>=0.6.0'} + + xpath@0.0.34: + resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} + engines: {node: '>=0.6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - xxhash-wasm@1.1.0: - resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} - y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -8204,15 +7560,6 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml-language-server@1.20.0: - resolution: {integrity: sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==} - hasBin: true - - yaml@2.7.1: - resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} - engines: {node: '>= 14'} - hasBin: true - yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -8254,14 +7601,6 @@ packages: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - - yocto-spinner@0.2.3: - resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} - engines: {node: '>=18.19'} - yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -8280,17 +7619,6 @@ packages: fast-check: '>2.23.0 <4.0.0' zod: ^3.18.0 - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod-to-ts@1.2.0: - resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} - peerDependencies: - typescript: ^4.9.4 || ^5.0.2 - zod: ^3 - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -8460,6 +7788,8 @@ snapshots: dependencies: '@algolia/client-common': 5.49.2 + '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -8485,98 +7815,6 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@astrojs/check@0.9.9(prettier@3.8.4)(typescript@5.9.3)': - dependencies: - '@astrojs/language-server': 2.16.10(prettier@3.8.4)(typescript@5.9.3) - chokidar: 4.0.3 - kleur: 4.1.5 - typescript: 5.9.3 - yargs: 17.7.2 - transitivePeerDependencies: - - prettier - - prettier-plugin-astro - - '@astrojs/compiler@2.13.1': {} - - '@astrojs/internal-helpers@0.7.6': {} - - '@astrojs/language-server@2.16.10(prettier@3.8.4)(typescript@5.9.3)': - dependencies: - '@astrojs/compiler': 2.13.1 - '@astrojs/yaml2ts': 0.2.4 - '@jridgewell/sourcemap-codec': 1.5.5 - '@volar/kit': 2.4.28(typescript@5.9.3) - '@volar/language-core': 2.4.28 - '@volar/language-server': 2.4.28 - '@volar/language-service': 2.4.28 - muggle-string: 0.4.1 - tinyglobby: 0.2.17 - volar-service-css: 0.0.70(@volar/language-service@2.4.28) - volar-service-emmet: 0.0.70(@volar/language-service@2.4.28) - volar-service-html: 0.0.70(@volar/language-service@2.4.28) - volar-service-prettier: 0.0.70(@volar/language-service@2.4.28)(prettier@3.8.4) - volar-service-typescript: 0.0.70(@volar/language-service@2.4.28) - volar-service-typescript-twoslash-queries: 0.0.70(@volar/language-service@2.4.28) - volar-service-yaml: 0.0.70(@volar/language-service@2.4.28) - vscode-html-languageservice: 5.6.2 - vscode-uri: 3.1.0 - optionalDependencies: - prettier: 3.8.4 - transitivePeerDependencies: - - typescript - - '@astrojs/markdown-remark@6.3.11': - dependencies: - '@astrojs/internal-helpers': 0.7.6 - '@astrojs/prism': 3.3.0 - github-slugger: 2.0.0 - hast-util-from-html: 2.0.3 - hast-util-to-text: 4.0.2 - import-meta-resolve: 4.2.0 - js-yaml: 4.2.0 - mdast-util-definitions: 6.0.0 - rehype-raw: 7.0.0 - rehype-stringify: 10.0.1 - remark-gfm: 4.0.1 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - remark-smartypants: 3.0.2 - shiki: 3.23.0 - smol-toml: 1.6.1 - unified: 11.0.5 - unist-util-remove-position: 5.0.0 - unist-util-visit: 5.1.0 - unist-util-visit-parents: 6.0.2 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - - '@astrojs/prism@3.3.0': - dependencies: - prismjs: 1.30.0 - - '@astrojs/sitemap@3.7.3': - dependencies: - sitemap: 9.0.1 - stream-replace-string: 2.0.0 - zod: 4.3.6 - - '@astrojs/telemetry@3.3.0': - dependencies: - ci-info: 4.4.0 - debug: 4.4.3 - dlv: 1.1.3 - dset: 3.1.4 - is-docker: 3.0.0 - is-wsl: 3.1.1 - which-pm-runs: 1.1.0 - transitivePeerDependencies: - - supports-color - - '@astrojs/yaml2ts@0.2.4': - dependencies: - yaml: 2.8.3 - '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -9160,10 +8398,6 @@ snapshots: dependencies: css-tree: 3.2.1 - '@capsizecss/unpack@4.0.1': - dependencies: - fontkitten: 1.0.3 - '@colors/colors@1.5.0': optional: true @@ -9245,29 +8479,6 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} - '@emmetio/abbreviation@2.3.3': - dependencies: - '@emmetio/scanner': 1.0.4 - - '@emmetio/css-abbreviation@2.1.8': - dependencies: - '@emmetio/scanner': 1.0.4 - - '@emmetio/css-parser@0.4.1': - dependencies: - '@emmetio/stream-reader': 2.2.0 - '@emmetio/stream-reader-utils': 0.1.0 - - '@emmetio/html-matcher@1.3.0': - dependencies: - '@emmetio/scanner': 1.0.4 - - '@emmetio/scanner@1.0.4': {} - - '@emmetio/stream-reader-utils@0.1.0': {} - - '@emmetio/stream-reader@2.2.0': {} - '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -9302,9 +8513,6 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.27.7': - optional: true - '@esbuild/aix-ppc64@0.28.0': optional: true @@ -9314,9 +8522,6 @@ snapshots: '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.27.7': - optional: true - '@esbuild/android-arm64@0.28.0': optional: true @@ -9326,9 +8531,6 @@ snapshots: '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.27.7': - optional: true - '@esbuild/android-arm@0.28.0': optional: true @@ -9338,9 +8540,6 @@ snapshots: '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.27.7': - optional: true - '@esbuild/android-x64@0.28.0': optional: true @@ -9350,9 +8549,6 @@ snapshots: '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.27.7': - optional: true - '@esbuild/darwin-arm64@0.28.0': optional: true @@ -9362,9 +8558,6 @@ snapshots: '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.27.7': - optional: true - '@esbuild/darwin-x64@0.28.0': optional: true @@ -9374,9 +8567,6 @@ snapshots: '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.27.7': - optional: true - '@esbuild/freebsd-arm64@0.28.0': optional: true @@ -9386,9 +8576,6 @@ snapshots: '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.27.7': - optional: true - '@esbuild/freebsd-x64@0.28.0': optional: true @@ -9398,9 +8585,6 @@ snapshots: '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.27.7': - optional: true - '@esbuild/linux-arm64@0.28.0': optional: true @@ -9410,9 +8594,6 @@ snapshots: '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.27.7': - optional: true - '@esbuild/linux-arm@0.28.0': optional: true @@ -9422,9 +8603,6 @@ snapshots: '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.27.7': - optional: true - '@esbuild/linux-ia32@0.28.0': optional: true @@ -9434,9 +8612,6 @@ snapshots: '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.27.7': - optional: true - '@esbuild/linux-loong64@0.28.0': optional: true @@ -9446,9 +8621,6 @@ snapshots: '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.27.7': - optional: true - '@esbuild/linux-mips64el@0.28.0': optional: true @@ -9458,9 +8630,6 @@ snapshots: '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.27.7': - optional: true - '@esbuild/linux-ppc64@0.28.0': optional: true @@ -9470,9 +8639,6 @@ snapshots: '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.27.7': - optional: true - '@esbuild/linux-riscv64@0.28.0': optional: true @@ -9482,9 +8648,6 @@ snapshots: '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.27.7': - optional: true - '@esbuild/linux-s390x@0.28.0': optional: true @@ -9494,18 +8657,12 @@ snapshots: '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.27.7': - optional: true - '@esbuild/linux-x64@0.28.0': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.27.7': - optional: true - '@esbuild/netbsd-arm64@0.28.0': optional: true @@ -9515,18 +8672,12 @@ snapshots: '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.27.7': - optional: true - '@esbuild/netbsd-x64@0.28.0': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.27.7': - optional: true - '@esbuild/openbsd-arm64@0.28.0': optional: true @@ -9536,18 +8687,12 @@ snapshots: '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.27.7': - optional: true - '@esbuild/openbsd-x64@0.28.0': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.7': - optional: true - '@esbuild/openharmony-arm64@0.28.0': optional: true @@ -9557,9 +8702,6 @@ snapshots: '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.27.7': - optional: true - '@esbuild/sunos-x64@0.28.0': optional: true @@ -9569,9 +8711,6 @@ snapshots: '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.27.7': - optional: true - '@esbuild/win32-arm64@0.28.0': optional: true @@ -9581,9 +8720,6 @@ snapshots: '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.27.7': - optional: true - '@esbuild/win32-ia32@0.28.0': optional: true @@ -9593,20 +8729,17 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.27.7': - optional: true - '@esbuild/win32-x64@0.28.0': optional: true - '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)': + '@exodus/bytes@1.15.0(@noble/hashes@2.2.0)': optionalDependencies: - '@noble/hashes': 1.8.0 + '@noble/hashes': 2.2.0 - '@fast-check/vitest@0.4.1(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3))': + '@fast-check/vitest@0.4.1(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3))': dependencies: fast-check: 4.8.0 - vitest: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + vitest: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) '@fast-csv/format@4.3.5': dependencies: @@ -10630,12 +9763,57 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + '@next/env@15.5.19': {} + + '@next/swc-darwin-arm64@15.5.19': + optional: true + + '@next/swc-darwin-x64@15.5.19': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.19': + optional: true + + '@next/swc-linux-arm64-musl@15.5.19': + optional: true + + '@next/swc-linux-x64-gnu@15.5.19': + optional: true + + '@next/swc-linux-x64-musl@15.5.19': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.19': + optional: true + + '@next/swc-win32-x64-msvc@15.5.19': + optional: true + '@noble/ciphers@1.3.0': {} '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} + '@nodable/entities@2.1.1': {} + '@node-saml/node-saml@5.1.0': + dependencies: + '@types/debug': 4.1.13 + '@types/qs': 6.15.1 + '@types/xml-encryption': 1.2.4 + '@types/xml2js': 0.4.14 + '@xmldom/is-dom-node': 1.0.1 + '@xmldom/xmldom': 0.8.13 + debug: 4.4.3 + xml-crypto: 6.1.2 + xml-encryption: 3.1.0 + xml2js: 0.6.2 + xmlbuilder: 15.1.1 + xpath: 0.0.34 + transitivePeerDependencies: + - supports-color + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.6': @@ -10732,8 +9910,6 @@ snapshots: '@opentelemetry/semantic-conventions@1.40.0': {} - '@oslojs/encoding@1.1.0': {} - '@oxc-project/types@0.133.0': {} '@petamoriken/float16@3.9.3': @@ -10819,12 +9995,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/pluginutils@5.4.0': - dependencies: - '@types/estree': 1.0.9 - estree-walker: 2.0.2 - picomatch: 4.0.4 - '@scalar/client-side-rendering@0.1.13': dependencies: '@scalar/schemas': 0.3.3 @@ -11073,51 +10243,25 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/core@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@2.5.0': dependencies: '@shikijs/types': 2.5.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 3.1.1 - '@shikijs/engine-javascript@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@2.5.0': dependencies: '@shikijs/types': 2.5.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/engine-oniguruma@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@2.5.0': dependencies: '@shikijs/types': 2.5.0 - '@shikijs/langs@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/themes@2.5.0': dependencies: '@shikijs/types': 2.5.0 - '@shikijs/themes@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/transformers@2.5.0': dependencies: '@shikijs/core': 2.5.0 @@ -11128,11 +10272,6 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/types@3.23.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - '@shikijs/vscode-textmate@10.0.2': {} '@simple-libs/stream-utils@1.2.0': {} @@ -11247,14 +10386,18 @@ snapshots: '@stryker-mutator/util@9.6.1': {} - '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.8.0))(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3))': + '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.8.0))(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3))': dependencies: '@stryker-mutator/api': 9.6.1 '@stryker-mutator/core': 9.6.1(@types/node@25.8.0) '@stryker-mutator/util': 9.6.1 semver: 7.8.1 tslib: 2.8.1 - vitest: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + vitest: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 '@swc/helpers@0.5.19': dependencies: @@ -11270,42 +10413,88 @@ snapshots: source-map-js: 1.2.1 tailwindcss: 4.3.0 + '@tailwindcss/node@4.3.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.1 + '@tailwindcss/oxide-android-arm64@4.3.0': optional: true + '@tailwindcss/oxide-android-arm64@4.3.1': + optional: true + '@tailwindcss/oxide-darwin-arm64@4.3.0': optional: true + '@tailwindcss/oxide-darwin-arm64@4.3.1': + optional: true + '@tailwindcss/oxide-darwin-x64@4.3.0': optional: true + '@tailwindcss/oxide-darwin-x64@4.3.1': + optional: true + '@tailwindcss/oxide-freebsd-x64@4.3.0': optional: true + '@tailwindcss/oxide-freebsd-x64@4.3.1': + optional: true + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': optional: true + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + optional: true + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': optional: true + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + optional: true + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': optional: true + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + optional: true + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': optional: true + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + optional: true + '@tailwindcss/oxide-linux-x64-musl@4.3.0': optional: true + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + optional: true + '@tailwindcss/oxide-wasm32-wasi@4.3.0': optional: true + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + optional: true + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': optional: true + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + optional: true + '@tailwindcss/oxide@4.3.0': optionalDependencies: '@tailwindcss/oxide-android-arm64': 4.3.0 @@ -11321,12 +10510,28 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3))': + '@tailwindcss/oxide@4.3.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-x64': 4.3.1 + '@tailwindcss/oxide-freebsd-x64': 4.3.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-x64-musl': 4.3.1 + '@tailwindcss/oxide-wasm32-wasi': 4.3.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 + + '@tailwindcss/postcss@4.3.1': dependencies: - '@tailwindcss/node': 4.3.0 - '@tailwindcss/oxide': 4.3.0 - tailwindcss: 4.3.0 - vite: 8.0.16(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.1 + '@tailwindcss/oxide': 4.3.1 + postcss: 8.5.15 + tailwindcss: 4.3.1 '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3))': dependencies: @@ -11500,10 +10705,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/nlcst@2.0.3': - dependencies: - '@types/unist': 3.0.3 - '@types/node@14.18.63': {} '@types/node@16.9.1': {} @@ -11516,10 +10717,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.13.2': - dependencies: - undici-types: 7.18.2 - '@types/node@25.8.0': dependencies: undici-types: 7.24.6 @@ -11557,6 +10754,8 @@ snapshots: dependencies: '@types/node': 22.19.19 + '@types/qs@6.15.1': {} + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 @@ -11583,10 +10782,6 @@ snapshots: dependencies: '@types/node': 22.19.19 - '@types/sax@1.2.7': - dependencies: - '@types/node': 22.19.19 - '@types/ssh2-streams@0.1.13': dependencies: '@types/node': 22.19.19 @@ -11611,6 +10806,14 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/xml-encryption@1.2.4': + dependencies: + '@types/node': 22.19.19 + + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 22.19.19 + '@types/yauzl@3.3.0': dependencies: '@types/node': 22.19.19 @@ -11641,7 +10844,7 @@ snapshots: vite: 8.0.16(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) vue: 3.5.30(typescript@5.9.3) - '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3))': + '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -11656,7 +10859,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) + vitest: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -11702,56 +10905,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@volar/kit@2.4.28(typescript@5.9.3)': - dependencies: - '@volar/language-service': 2.4.28 - '@volar/typescript': 2.4.28 - typesafe-path: 0.2.2 - typescript: 5.9.3 - vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.1.0 - - '@volar/language-core@2.4.28': - dependencies: - '@volar/source-map': 2.4.28 - - '@volar/language-server@2.4.28': - dependencies: - '@volar/language-core': 2.4.28 - '@volar/language-service': 2.4.28 - '@volar/typescript': 2.4.28 - path-browserify: 1.0.1 - request-light: 0.7.0 - vscode-languageserver: 9.0.1 - vscode-languageserver-protocol: 3.18.0 - vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.1.0 - - '@volar/language-service@2.4.28': - dependencies: - '@volar/language-core': 2.4.28 - vscode-languageserver-protocol: 3.18.0 - vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.1.0 - - '@volar/source-map@2.4.28': {} - - '@volar/typescript@2.4.28': - dependencies: - '@volar/language-core': 2.4.28 - path-browserify: 1.0.1 - vscode-uri: 3.1.0 - - '@vscode/emmet-helper@2.11.0': - dependencies: - emmet: 2.4.11 - jsonc-parser: 2.3.1 - vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.18.0 - vscode-uri: 3.1.0 - - '@vscode/l10n@0.0.18': {} - '@vue/compiler-core@3.5.30': dependencies: '@babel/parser': 7.29.7 @@ -11853,6 +11006,10 @@ snapshots: transitivePeerDependencies: - typescript + '@xmldom/is-dom-node@1.0.1': {} + + '@xmldom/xmldom@0.8.13': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -11913,10 +11070,6 @@ snapshots: angular-html-parser@10.4.0: {} - ansi-align@3.0.1: - dependencies: - string-width: 4.2.3 - ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -11941,11 +11094,6 @@ snapshots: any-promise@1.3.0: {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 4.0.4 - archiver-utils@2.1.0: dependencies: glob: 13.0.6 @@ -12006,8 +11154,6 @@ snapshots: - bare-buffer - react-native-b4a - arg@5.0.2: {} - argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -12024,8 +11170,6 @@ snapshots: array-ify@1.0.0: {} - array-iterate@2.0.1: {} - asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -12038,108 +11182,6 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - astro@5.18.2(@types/node@25.8.0)(ioredis@5.10.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.8.3): - dependencies: - '@astrojs/compiler': 2.13.1 - '@astrojs/internal-helpers': 0.7.6 - '@astrojs/markdown-remark': 6.3.11 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 4.0.1 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.4.0 - acorn: 8.16.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.4.0 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.1 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.8.1 - diff: 8.0.4 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.27.7 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.4.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.2.0 - magic-string: 0.30.21 - magicast: 0.5.3 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.6.0 - piccolore: 0.1.3 - picomatch: 4.0.4 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.8.1 - shiki: 3.23.0 - smol-toml: 1.6.1 - svgo: 4.0.1 - tinyexec: 1.0.4 - tinyglobby: 0.2.17 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.7.4 - unist-util-visit: 5.1.0 - unstorage: 1.17.5(ioredis@5.10.1) - vfile: 6.0.3 - vite: 8.0.16(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) - vitefu: 1.1.3(vite@8.0.16(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - '@vitejs/devtools' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - async-lock@1.4.1: {} async@3.2.6: {} @@ -12151,8 +11193,6 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 - axobject-query@4.1.0: {} - b4a@1.8.0: {} bail@2.0.2: {} @@ -12194,8 +11234,6 @@ snapshots: dependencies: bare-path: 3.0.0 - base-64@1.0.0: {} - base64-js@0.0.8: {} base64-js@1.5.1: {} @@ -12242,23 +11280,10 @@ snapshots: bmp-js@0.1.0: {} - boolbase@1.0.0: {} - bottleneck@2.19.5: {} bowser@2.14.1: {} - boxen@8.0.1: - dependencies: - ansi-align: 3.0.1 - camelcase: 8.0.0 - chalk: 5.6.2 - cli-boxes: 3.0.0 - string-width: 7.2.0 - type-fest: 4.41.0 - widest-line: 5.0.0 - wrap-ansi: 9.0.2 - brace-expansion@2.1.1: dependencies: balanced-match: 1.0.2 @@ -12344,8 +11369,6 @@ snapshots: camelcase@5.3.1: {} - camelcase@8.0.0: {} - caniuse-lite@1.0.30001780: {} ccount@2.0.1: {} @@ -12393,20 +11416,10 @@ snapshots: check-error@2.1.3: {} - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - chokidar@5.0.0: - dependencies: - readdirp: 5.0.0 - chownr@1.1.4: {} chownr@3.0.0: {} - ci-info@4.4.0: {} - cjs-module-lexer@2.2.0: {} clean-stack@2.2.0: {} @@ -12415,8 +11428,6 @@ snapshots: dependencies: escape-string-regexp: 5.0.0 - cli-boxes@3.0.0: {} - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -12443,6 +11454,8 @@ snapshots: cli-width@4.1.0: {} + client-only@0.0.1: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -12489,15 +11502,11 @@ snapshots: comma-separated-tokens@2.0.3: {} - commander@11.1.0: {} - commander@14.0.3: {} commander@2.20.3: optional: true - common-ancestor-path@1.0.1: {} - compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -12559,8 +11568,6 @@ snapshots: convert-source-map@2.0.0: {} - cookie-es@1.2.3: {} - cookie@1.1.1: {} copy-anything@4.0.5: @@ -12608,48 +11615,23 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - crossws@0.3.5: - dependencies: - uncrypto: 0.1.3 - crypto-random-string@4.0.0: dependencies: type-fest: 1.4.0 - css-select@5.2.2: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 - - css-tree@2.2.1: - dependencies: - mdn-data: 2.0.28 - source-map-js: 1.2.1 - css-tree@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 - css-what@6.2.2: {} - css.escape@1.5.1: {} - cssesc@3.0.0: {} - - csso@5.0.5: - dependencies: - css-tree: 2.2.1 - csstype@3.2.3: {} - data-urls@7.0.0(@noble/hashes@1.8.0): + data-urls@7.0.0(@noble/hashes@2.2.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@1.8.0) + whatwg-url: 16.0.1(@noble/hashes@2.2.0) transitivePeerDependencies: - '@noble/hashes' @@ -12677,8 +11659,6 @@ snapshots: deep-extend@0.6.0: {} - defu@6.1.7: {} - denque@2.1.0: {} depd@2.0.0: {} @@ -12690,16 +11670,8 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 - destr@2.0.5: {} - detect-libc@2.1.2: {} - deterministic-object-hash@2.0.2: - dependencies: - base-64: 1.0.0 - - devalue@5.8.1: {} - devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -12708,16 +11680,12 @@ snapshots: diff-match-patch@1.0.5: {} - diff@8.0.4: {} - dijkstrajs@1.0.3: {} dir-glob@3.0.1: dependencies: path-type: 4.0.0 - dlv@1.1.3: {} - docker-compose@1.4.2: dependencies: yaml: 2.8.3 @@ -12746,30 +11714,12 @@ snapshots: dom-accessibility-api@0.6.3: {} - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - dom-walk@0.1.2: {} - domelementtype@2.3.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - dompurify@3.4.1: optionalDependencies: '@types/trusted-types': 2.0.7 - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -12792,8 +11742,6 @@ snapshots: gel: 2.2.0 pg: 8.21.0 - dset@3.1.4: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -12808,11 +11756,6 @@ snapshots: electron-to-chromium@1.5.321: {} - emmet@2.4.11: - dependencies: - '@emmetio/abbreviation': 2.3.3 - '@emmetio/css-abbreviation': 2.1.8 - emoji-regex-xs@1.0.0: {} emoji-regex@10.6.0: {} @@ -12832,9 +11775,12 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 - entities@4.5.0: {} + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 - entities@6.0.1: {} + entities@4.5.0: {} entities@7.0.1: {} @@ -12920,35 +11866,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.0: optionalDependencies: '@esbuild/aix-ppc64': 0.28.0 @@ -13215,18 +12132,12 @@ snapshots: semver-regex: 4.0.5 super-regex: 1.1.0 - flattie@1.1.1: {} - focus-trap@7.8.0: dependencies: tabbable: 6.4.0 follow-redirects@1.16.0: {} - fontace@0.4.1: - dependencies: - fontkitten: 1.0.3 - fontkit@2.0.4: dependencies: '@swc/helpers': 0.5.19 @@ -13239,10 +12150,6 @@ snapshots: unicode-properties: 1.4.1 unicode-trie: 2.0.0 - fontkitten@1.0.3: - dependencies: - tiny-inflate: 1.0.3 - foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -13250,6 +12157,15 @@ snapshots: format@0.2.2: {} + framer-motion@11.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 11.18.1 + motion-utils: 11.18.1 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + fs-constants@1.0.0: {} fs-extra@11.3.4: @@ -13379,18 +12295,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - h3@1.15.11: - dependencies: - cookie-es: 1.2.3 - crossws: 0.3.5 - defu: 6.1.7 - destr: 2.0.5 - iron-webcrypto: 1.2.1 - node-mock-http: 1.0.4 - radix3: 1.1.2 - ufo: 1.6.4 - uncrypto: 0.1.3 - handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -13410,50 +12314,6 @@ snapshots: dependencies: function-bind: 1.1.2 - hast-util-from-html@2.0.3: - dependencies: - '@types/hast': 3.0.4 - devlop: 1.1.0 - hast-util-from-parse5: 8.0.3 - parse5: 7.3.0 - vfile: 6.0.3 - vfile-message: 4.0.3 - - hast-util-from-parse5@8.0.3: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - devlop: 1.1.0 - hastscript: 9.0.1 - property-information: 7.1.0 - vfile: 6.0.3 - vfile-location: 5.0.3 - web-namespaces: 2.0.1 - - hast-util-is-element@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - hast-util-parse-selector@4.0.0: - dependencies: - '@types/hast': 3.0.4 - - hast-util-raw@9.1.0: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - '@ungap/structured-clone': 1.3.0 - hast-util-from-parse5: 8.0.3 - hast-util-to-parse5: 8.0.1 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - parse5: 7.3.0 - unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -13468,35 +12328,10 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-parse5@8.0.1: - dependencies: - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - web-namespaces: 2.0.1 - zwitch: 2.0.4 - - hast-util-to-text@4.0.2: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - hast-util-is-element: 3.0.0 - unist-util-find-after: 5.0.0 - hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 - hastscript@9.0.1: - dependencies: - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - hast-util-parse-selector: 4.0.0 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - highlight.js@10.7.3: {} hook-std@4.0.0: {} @@ -13511,20 +12346,16 @@ snapshots: dependencies: lru-cache: 11.4.0 - html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): dependencies: - '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + '@exodus/bytes': 1.15.0(@noble/hashes@2.2.0) transitivePeerDependencies: - '@noble/hashes' html-escaper@2.0.2: {} - html-escaper@3.0.3: {} - html-void-elements@3.0.0: {} - http-cache-semantics@4.2.0: {} - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -13614,12 +12445,8 @@ snapshots: ipaddr.js@2.3.0: {} - iron-webcrypto@1.2.1: {} - is-arrayish@0.2.1: {} - is-docker@3.0.0: {} - is-extendable@0.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -13630,10 +12457,6 @@ snapshots: is-function@1.0.2: {} - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - is-number@7.0.0: {} is-obj@2.0.0: {} @@ -13652,10 +12475,6 @@ snapshots: is-what@5.5.0: {} - is-wsl@3.1.1: - dependencies: - is-inside-container: 1.0.0 - isarray@1.0.0: {} isexe@2.0.0: {} @@ -13752,17 +12571,17 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@29.1.1(@noble/hashes@1.8.0): + jsdom@29.1.1(@noble/hashes@2.2.0): dependencies: '@asamuzakjp/css-color': 5.1.11 '@asamuzakjp/dom-selector': 7.1.1 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) - '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + '@exodus/bytes': 1.15.0(@noble/hashes@2.2.0) css-tree: 3.2.1 - data-urls: 7.0.0(@noble/hashes@1.8.0) + data-urls: 7.0.0(@noble/hashes@2.2.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) is-potential-custom-element-name: 1.0.1 lru-cache: 11.4.0 parse5: 8.0.1 @@ -13773,7 +12592,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@1.8.0) + whatwg-url: 16.0.1(@noble/hashes@2.2.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -13796,10 +12615,6 @@ snapshots: json5@2.2.3: {} - jsonc-parser@2.3.1: {} - - jsonc-parser@3.3.1: {} - jsonfile@6.2.0: dependencies: universalify: 2.0.1 @@ -13817,10 +12632,6 @@ snapshots: kind-of@6.0.3: {} - kleur@3.0.3: {} - - kleur@4.1.5: {} - konva@10.3.0: {} lazystream@1.0.1: @@ -14035,12 +12846,6 @@ snapshots: '@babel/types': 7.29.7 source-map-js: 1.2.1 - magicast@0.5.3: - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - source-map-js: 1.2.1 - make-asynchronous@1.1.0: dependencies: p-event: 6.0.1 @@ -14062,8 +12867,6 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 - markdown-table@3.0.4: {} - markdown-title@1.0.2: {} marked-terminal@7.3.0(marked@15.0.12): @@ -14081,19 +12884,6 @@ snapshots: math-intrinsics@1.1.0: {} - mdast-util-definitions@6.0.0: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - unist-util-visit: 5.1.0 - - mdast-util-find-and-replace@3.0.2: - dependencies: - '@types/mdast': 4.0.4 - escape-string-regexp: 5.0.0 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 @@ -14122,63 +12912,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm-autolink-literal@2.0.1: - dependencies: - '@types/mdast': 4.0.4 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.2 - micromark-util-character: 2.1.1 - - mdast-util-gfm-footnote@2.1.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - micromark-util-normalize-identifier: 2.0.1 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-strikethrough@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-table@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-task-list-item@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm@3.1.0: - dependencies: - mdast-util-from-markdown: 2.0.3 - mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -14212,8 +12945,6 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - mdn-data@2.0.28: {} - mdn-data@2.27.1: {} mdurl@2.0.0: {} @@ -14248,64 +12979,6 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-gfm-autolink-literal@2.1.0: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-footnote@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-strikethrough@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-table@2.1.1: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-tagfilter@2.0.0: - dependencies: - micromark-util-types: 2.0.2 - - micromark-extension-gfm-task-list-item@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm@3.0.0: - dependencies: - micromark-extension-gfm-autolink-literal: 2.1.0 - micromark-extension-gfm-footnote: 2.1.0 - micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.1 - micromark-extension-gfm-tagfilter: 2.0.0 - micromark-extension-gfm-task-list-item: 2.1.0 - micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 - micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -14481,7 +13154,11 @@ snapshots: module-details-from-path@1.0.4: {} - mrmime@2.0.1: {} + motion-dom@11.18.1: + dependencies: + motion-utils: 11.18.1 + + motion-utils@11.18.1: {} ms@2.1.3: {} @@ -14501,8 +13178,6 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.4 - muggle-string@0.4.1: {} - mupdf@1.27.0: {} mutation-server-protocol@0.4.1: @@ -14536,13 +13211,32 @@ snapshots: neo-async@2.6.2: {} - neotraverse@0.6.18: {} - nerf-dart@1.0.0: {} - nlcst-to-string@4.0.0: + next@15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@types/nlcst': 2.0.3 + '@next/env': 15.5.19 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001780 + postcss: 8.5.15 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(react@19.2.7) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.19 + '@next/swc-darwin-x64': 15.5.19 + '@next/swc-linux-arm64-gnu': 15.5.19 + '@next/swc-linux-arm64-musl': 15.5.19 + '@next/swc-linux-x64-gnu': 15.5.19 + '@next/swc-linux-x64-musl': 15.5.19 + '@next/swc-win32-arm64-msvc': 15.5.19 + '@next/swc-win32-x64-msvc': 15.5.19 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.60.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros node-abi@3.89.0: dependencies: @@ -14557,15 +13251,11 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 - node-fetch-native@1.6.7: {} - node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 optional: true - node-mock-http@1.0.4: {} - node-releases@2.0.36: {} normalize-package-data@6.0.2: @@ -14599,24 +13289,12 @@ snapshots: npm@11.12.0: {} - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - oauth4webapi@3.8.6: {} object-assign@4.1.1: {} object-inspect@1.13.4: {} - ofetch@1.5.1: - dependencies: - destr: 2.0.5 - node-fetch-native: 1.6.7 - ufo: 1.6.4 - - ohash@2.0.11: {} - omggif@1.0.10: {} on-exit-leak-free@2.1.2: {} @@ -14637,20 +13315,12 @@ snapshots: dependencies: mimic-function: 5.0.1 - oniguruma-parser@0.12.2: {} - oniguruma-to-es@3.1.1: dependencies: emoji-regex-xs: 1.0.0 regex: 6.1.0 regex-recursion: 6.0.2 - oniguruma-to-es@4.3.6: - dependencies: - oniguruma-parser: 0.12.2 - regex: 6.1.0 - regex-recursion: 6.0.2 - openid-client@6.8.4: dependencies: jose: 6.2.3 @@ -14658,6 +13328,10 @@ snapshots: opentype.js@2.0.0: {} + otpauth@9.5.1: + dependencies: + '@noble/hashes': 2.2.0 + p-each-series@3.0.0: {} p-event@6.0.1: @@ -14676,10 +13350,6 @@ snapshots: dependencies: p-try: 2.2.0 - p-limit@6.2.0: - dependencies: - yocto-queue: 1.2.2 - p-locate@2.0.0: dependencies: p-limit: 1.3.0 @@ -14690,11 +13360,6 @@ snapshots: p-map@7.0.4: {} - p-queue@8.1.1: - dependencies: - eventemitter3: 5.0.4 - p-timeout: 6.1.4 - p-queue@9.3.0: dependencies: eventemitter3: 5.0.4 @@ -14714,8 +13379,6 @@ snapshots: package-json-from-dist@1.0.1: {} - package-manager-detector@1.6.0: {} - pako@0.2.9: {} pako@1.0.11: {} @@ -14755,15 +13418,6 @@ snapshots: index-to-position: 1.2.0 type-fest: 4.41.0 - parse-latin@7.0.0: - dependencies: - '@types/nlcst': 2.0.3 - '@types/unist': 3.0.3 - nlcst-to-string: 4.0.0 - unist-util-modify-children: 4.0.0 - unist-util-visit-children: 3.0.0 - vfile: 6.0.3 - parse-ms@4.0.0: {} parse5-htmlparser2-tree-adapter@6.0.1: @@ -14774,16 +13428,10 @@ snapshots: parse5@6.0.1: {} - parse5@7.3.0: - dependencies: - entities: 6.0.1 - parse5@8.0.1: dependencies: entities: 8.0.0 - path-browserify@1.0.1: {} - path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -14872,8 +13520,6 @@ snapshots: transitivePeerDependencies: - debug - piccolore@0.1.3: {} - picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -14984,8 +13630,6 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 - prettier@3.8.4: {} - pretty-bytes@7.1.0: {} pretty-format@27.5.1: @@ -14998,8 +13642,6 @@ snapshots: dependencies: parse-ms: 4.0.0 - prismjs@1.30.0: {} - process-nextick-args@2.0.1: {} process-warning@4.0.1: {} @@ -15015,11 +13657,6 @@ snapshots: '@opentelemetry/api': 1.9.1 tdigest: 0.1.2 - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -15072,8 +13709,6 @@ snapshots: quick-format-unescaped@4.0.4: {} - radix3@1.1.2: {} - rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -15209,10 +13844,6 @@ snapshots: dependencies: minimatch: 10.2.5 - readdirp@4.1.2: {} - - readdirp@5.0.0: {} - real-require@0.2.0: {} redent@3.0.0: @@ -15242,31 +13873,6 @@ snapshots: dependencies: '@pnpm/npm-conf': 3.0.2 - rehype-parse@9.0.1: - dependencies: - '@types/hast': 3.0.4 - hast-util-from-html: 2.0.3 - unified: 11.0.5 - - rehype-raw@7.0.0: - dependencies: - '@types/hast': 3.0.4 - hast-util-raw: 9.1.0 - vfile: 6.0.3 - - rehype-stringify@10.0.1: - dependencies: - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - unified: 11.0.5 - - rehype@13.0.2: - dependencies: - '@types/hast': 3.0.4 - rehype-parse: 9.0.1 - rehype-stringify: 10.0.1 - unified: 11.0.5 - remark-frontmatter@5.0.0: dependencies: '@types/mdast': 4.0.4 @@ -15276,17 +13882,6 @@ snapshots: transitivePeerDependencies: - supports-color - remark-gfm@4.0.1: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 - micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 - remark-stringify: 11.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -15296,21 +13891,6 @@ snapshots: transitivePeerDependencies: - supports-color - remark-rehype@11.1.2: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.1 - unified: 11.0.5 - vfile: 6.0.3 - - remark-smartypants@3.0.2: - dependencies: - retext: 9.0.0 - retext-smartypants: 6.2.0 - unified: 11.0.5 - unist-util-visit: 5.1.0 - remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -15326,10 +13906,6 @@ snapshots: transitivePeerDependencies: - supports-color - request-light@0.5.8: {} - - request-light@0.7.0: {} - require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -15358,31 +13934,6 @@ snapshots: ret@0.5.0: {} - retext-latin@4.0.0: - dependencies: - '@types/nlcst': 2.0.3 - parse-latin: 7.0.0 - unified: 11.0.5 - - retext-smartypants@6.2.0: - dependencies: - '@types/nlcst': 2.0.3 - nlcst-to-string: 4.0.0 - unist-util-visit: 5.1.0 - - retext-stringify@4.0.0: - dependencies: - '@types/nlcst': 2.0.3 - nlcst-to-string: 4.0.0 - unified: 11.0.5 - - retext@9.0.0: - dependencies: - '@types/nlcst': 2.0.3 - retext-latin: 4.0.0 - retext-stringify: 4.0.0 - unified: 11.0.5 - retry@0.12.0: {} reusify@1.1.0: {} @@ -15559,17 +14110,6 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - shiki@3.23.0: - dependencies: - '@shikijs/core': 3.23.0 - '@shikijs/engine-javascript': 3.23.0 - '@shikijs/engine-oniguruma': 3.23.0 - '@shikijs/langs': 3.23.0 - '@shikijs/themes': 3.23.0 - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -15618,15 +14158,6 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - sisteransi@1.0.5: {} - - sitemap@9.0.1: - dependencies: - '@types/node': 24.13.2 - '@types/sax': 1.2.7 - arg: 5.0.2 - sax: 1.6.0 - skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 @@ -15641,8 +14172,6 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - smol-toml@1.6.1: {} - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -15724,8 +14253,6 @@ snapshots: duplexer2: 0.1.4 readable-stream: 2.3.8 - stream-replace-string@2.0.0: {} - streamx@2.25.0: dependencies: events-universal: 1.0.1 @@ -15808,6 +14335,11 @@ snapshots: '@tokenizer/token': 0.3.0 peek-readable: 4.1.0 + styled-jsx@5.1.6(react@19.2.7): + dependencies: + client-only: 0.0.1 + react: 19.2.7 + super-regex@1.1.0: dependencies: function-timeout: 1.0.2 @@ -15831,16 +14363,6 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 - svgo@4.0.1: - dependencies: - commander: 11.1.0 - css-select: 5.2.2 - css-tree: 3.2.1 - css-what: 6.2.2 - csso: 5.0.5 - picocolors: 1.1.1 - sax: 1.6.0 - symbol-tree@3.2.4: {} tabbable@6.4.0: {} @@ -15851,6 +14373,8 @@ snapshots: tailwindcss@4.3.0: {} + tailwindcss@4.3.1: {} + tapable@2.3.3: {} tar-fs@2.1.4: @@ -16052,10 +14576,6 @@ snapshots: trough@2.2.0: {} - tsconfck@3.1.6(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 - tslib@2.8.1: {} tsx@4.22.4: @@ -16105,33 +14625,19 @@ snapshots: tunnel: 0.0.6 underscore: 1.13.8 - typesafe-path@0.2.2: {} - - typescript-auto-import-cache@0.3.6: - dependencies: - semver: 7.8.1 - typescript@5.9.3: {} uc.micro@2.1.0: {} - ufo@1.6.4: {} - uglify-js@3.19.3: optional: true - ultrahtml@1.6.0: {} - - uncrypto@0.1.3: {} - underscore@1.13.8: {} undici-types@5.26.5: {} undici-types@6.21.0: {} - undici-types@7.18.2: {} - undici-types@7.24.6: optional: true @@ -16167,39 +14673,18 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unifont@0.7.4: - dependencies: - css-tree: 3.2.1 - ofetch: 1.5.1 - ohash: 2.0.11 - unique-string@3.0.0: dependencies: crypto-random-string: 4.0.0 - unist-util-find-after@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 - unist-util-modify-children@4.0.0: - dependencies: - '@types/unist': 3.0.3 - array-iterate: 2.0.1 - unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 - unist-util-remove-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-visit: 5.1.0 - unist-util-remove@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -16210,10 +14695,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 - unist-util-visit-children@3.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-visit-parents@6.0.2: dependencies: '@types/unist': 3.0.3 @@ -16229,19 +14710,6 @@ snapshots: universalify@2.0.1: {} - unstorage@1.17.5(ioredis@5.10.1): - dependencies: - anymatch: 3.1.3 - chokidar: 5.0.0 - destr: 2.0.5 - h3: 1.15.11 - lru-cache: 11.4.0 - node-fetch-native: 1.6.7 - ofetch: 1.5.1 - ufo: 1.6.4 - optionalDependencies: - ioredis: 5.10.1 - unzipper@0.10.14: dependencies: big-integer: 1.6.52 @@ -16281,11 +14749,6 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vfile-location@5.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile: 6.0.3 - vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -16318,22 +14781,6 @@ snapshots: - tsx - yaml - vite@8.0.16(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.3 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 25.8.0 - esbuild: 0.27.7 - fsevents: 2.3.3 - jiti: 2.7.0 - terser: 5.47.1 - tsx: 4.22.4 - yaml: 2.8.3 - vite@8.0.16(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -16350,10 +14797,6 @@ snapshots: tsx: 4.22.4 yaml: 2.8.3 - vitefu@1.1.3(vite@8.0.16(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)): - optionalDependencies: - vite: 8.0.16(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3) - vitepress-plugin-llms@1.13.1: dependencies: gray-matter: 4.0.3 @@ -16426,7 +14869,7 @@ snapshots: - universal-cookie - yaml - vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3): + vitest@3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 @@ -16454,7 +14897,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.13 '@types/node': 25.8.0 - jsdom: 29.1.1(@noble/hashes@1.8.0) + jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - '@vitejs/devtools' - esbuild @@ -16470,112 +14913,6 @@ snapshots: - tsx - yaml - volar-service-css@0.0.70(@volar/language-service@2.4.28): - dependencies: - vscode-css-languageservice: 6.3.10 - vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.1.0 - optionalDependencies: - '@volar/language-service': 2.4.28 - - volar-service-emmet@0.0.70(@volar/language-service@2.4.28): - dependencies: - '@emmetio/css-parser': 0.4.1 - '@emmetio/html-matcher': 1.3.0 - '@vscode/emmet-helper': 2.11.0 - vscode-uri: 3.1.0 - optionalDependencies: - '@volar/language-service': 2.4.28 - - volar-service-html@0.0.70(@volar/language-service@2.4.28): - dependencies: - vscode-html-languageservice: 5.6.2 - vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.1.0 - optionalDependencies: - '@volar/language-service': 2.4.28 - - volar-service-prettier@0.0.70(@volar/language-service@2.4.28)(prettier@3.8.4): - dependencies: - vscode-uri: 3.1.0 - optionalDependencies: - '@volar/language-service': 2.4.28 - prettier: 3.8.4 - - volar-service-typescript-twoslash-queries@0.0.70(@volar/language-service@2.4.28): - dependencies: - vscode-uri: 3.1.0 - optionalDependencies: - '@volar/language-service': 2.4.28 - - volar-service-typescript@0.0.70(@volar/language-service@2.4.28): - dependencies: - path-browserify: 1.0.1 - semver: 7.8.1 - typescript-auto-import-cache: 0.3.6 - vscode-languageserver-textdocument: 1.0.12 - vscode-nls: 5.2.0 - vscode-uri: 3.1.0 - optionalDependencies: - '@volar/language-service': 2.4.28 - - volar-service-yaml@0.0.70(@volar/language-service@2.4.28): - dependencies: - vscode-uri: 3.1.0 - yaml-language-server: 1.20.0 - optionalDependencies: - '@volar/language-service': 2.4.28 - - vscode-css-languageservice@6.3.10: - dependencies: - '@vscode/l10n': 0.0.18 - vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.17.5 - vscode-uri: 3.1.0 - - vscode-html-languageservice@5.6.2: - dependencies: - '@vscode/l10n': 0.0.18 - vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.18.0 - vscode-uri: 3.1.0 - - vscode-json-languageservice@4.1.8: - dependencies: - jsonc-parser: 3.3.1 - vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.18.0 - vscode-nls: 5.2.0 - vscode-uri: 3.1.0 - - vscode-jsonrpc@8.2.0: {} - - vscode-jsonrpc@9.0.0: {} - - vscode-languageserver-protocol@3.17.5: - dependencies: - vscode-jsonrpc: 8.2.0 - vscode-languageserver-types: 3.17.5 - - vscode-languageserver-protocol@3.18.0: - dependencies: - vscode-jsonrpc: 9.0.0 - vscode-languageserver-types: 3.18.0 - - vscode-languageserver-textdocument@1.0.12: {} - - vscode-languageserver-types@3.17.5: {} - - vscode-languageserver-types@3.18.0: {} - - vscode-languageserver@9.0.1: - dependencies: - vscode-languageserver-protocol: 3.17.5 - - vscode-nls@5.2.0: {} - - vscode-uri@3.1.0: {} - vue@3.5.30(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.30 @@ -16592,8 +14929,6 @@ snapshots: weapon-regex@1.3.6: {} - web-namespaces@2.0.1: {} - web-vitals@5.2.0: {} web-worker@1.5.0: {} @@ -16602,9 +14937,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1(@noble/hashes@1.8.0): + whatwg-url@16.0.1(@noble/hashes@2.2.0): dependencies: - '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + '@exodus/bytes': 1.15.0(@noble/hashes@2.2.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: @@ -16612,8 +14947,6 @@ snapshots: which-module@2.0.1: {} - which-pm-runs@1.1.0: {} - which@2.0.2: dependencies: isexe: 2.0.0 @@ -16628,10 +14961,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - widest-line@5.0.0: - dependencies: - string-width: 7.2.0 - wordwrap@1.0.0: {} wrap-ansi@6.2.0: @@ -16667,6 +14996,18 @@ snapshots: parse-headers: 2.0.6 xtend: 4.0.2 + xml-crypto@6.1.2: + dependencies: + '@xmldom/is-dom-node': 1.0.1 + '@xmldom/xmldom': 0.8.13 + xpath: 0.0.33 + + xml-encryption@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + escape-html: 1.0.3 + xpath: 0.0.32 + xml-name-validator@5.0.0: {} xml-naming@0.1.0: {} @@ -16678,13 +15019,24 @@ snapshots: sax: 1.6.0 xmlbuilder: 11.0.1 + xml2js@0.6.2: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} - xtend@4.0.2: {} + xpath@0.0.32: {} - xxhash-wasm@1.1.0: {} + xpath@0.0.33: {} + + xpath@0.0.34: {} + + xtend@4.0.2: {} y18n@4.0.3: {} @@ -16694,22 +15046,6 @@ snapshots: yallist@5.0.0: {} - yaml-language-server@1.20.0: - dependencies: - '@vscode/l10n': 0.0.18 - ajv: 8.18.0 - ajv-draft-04: 1.0.0(ajv@8.18.0) - prettier: 3.8.4 - request-light: 0.5.8 - vscode-json-languageservice: 4.1.8 - vscode-languageserver: 9.0.1 - vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.18.0 - vscode-uri: 3.1.0 - yaml: 2.7.1 - - yaml@2.7.1: {} - yaml@2.8.3: {} yargs-parser@18.1.3: @@ -16770,12 +15106,6 @@ snapshots: dependencies: pend: 1.2.0 - yocto-queue@1.2.2: {} - - yocto-spinner@0.2.3: - dependencies: - yoctocolors: 2.1.2 - yoctocolors@2.1.2: {} zip-stream@4.1.1: @@ -16795,15 +15125,6 @@ snapshots: fast-check: 4.8.0 zod: 4.3.6 - zod-to-json-schema@3.25.2(zod@3.25.76): - dependencies: - zod: 3.25.76 - - zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): - dependencies: - typescript: 5.9.3 - zod: 3.25.76 - zod@3.25.76: {} zod@4.3.6: {} diff --git a/scripts/check-license-boundary.mjs b/scripts/check-license-boundary.mjs index 77f547b0..8e355fa7 100644 --- a/scripts/check-license-boundary.mjs +++ b/scripts/check-license-boundary.mjs @@ -23,7 +23,8 @@ const ENTERPRISE_DIR = join(ROOT, "packages/enterprise"); // Catches static `from "..."`, side-effect `import "..."`, and dynamic `import("...")` // forms. The bare package entry "@snapotter/enterprise" (no trailing slash) stays // allowed; only reaching INTO the package is a violation. -const INTERNAL_IMPORT = /(from|import)\s*[\s(]\s*["'](@snapotter\/enterprise\/|[./]*packages\/enterprise\/)/; +const INTERNAL_IMPORT = + /(from|import)\s*[\s(]\s*["'](@snapotter\/enterprise\/|[./]*packages\/enterprise\/)/; const APP_IMPORT = /(from|import)\s*[\s(]\s*["'][^"']*apps\/(api|web)\//; const violations = []; diff --git a/scripts/export-ai-bundle.mjs b/scripts/export-ai-bundle.mjs index 67ef75a4..0eeb3e58 100755 --- a/scripts/export-ai-bundle.mjs +++ b/scripts/export-ai-bundle.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; /** * Export an installed AI feature bundle as a gzipped tar archive for offline * transfer to air-gapped SnapOtter installations. @@ -18,11 +20,9 @@ * createRequire, same pattern as tests/global-setup.ts). */ import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; -import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; -import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; -import { randomUUID } from "node:crypto"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const apiRequire = createRequire(join(__dirname, "../apps/api/package.json")); @@ -136,7 +136,9 @@ if (modelEntries.length === 0) { process.stderr.write("Warning: no model files found; archive will only contain bundle.json\n"); } -process.stderr.write(`Exporting bundle "${bundleId}" v${version} (${modelEntries.length} model files)...\n`); +process.stderr.write( + `Exporting bundle "${bundleId}" v${version} (${modelEntries.length} model files)...\n`, +); // ── Create the archive ───────────────────────────────────────────────── diff --git a/scripts/generate-hostile-fixtures.mjs b/scripts/generate-hostile-fixtures.mjs index 95e0664b..efcac2b9 100644 --- a/scripts/generate-hostile-fixtures.mjs +++ b/scripts/generate-hostile-fixtures.mjs @@ -19,7 +19,10 @@ mkdirSync(outDir, { recursive: true }); // --- 1. truncated.jpg: a real JPEG cut off at 40% --------------------------- const realJpeg = readFileSync(join(root, "tests", "fixtures", "sample-photo.jpg")); -writeFileSync(join(outDir, "truncated.jpg"), realJpeg.subarray(0, Math.floor(realJpeg.length * 0.4))); +writeFileSync( + join(outDir, "truncated.jpg"), + realJpeg.subarray(0, Math.floor(realJpeg.length * 0.4)), +); // --- 2. zero-byte.png -------------------------------------------------------- writeFileSync(join(outDir, "zero-byte.png"), Buffer.alloc(0)); diff --git a/scripts/generate-test-fixtures.mjs b/scripts/generate-test-fixtures.mjs index cd813fd9..02c19f3b 100644 --- a/scripts/generate-test-fixtures.mjs +++ b/scripts/generate-test-fixtures.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; /** * Generates the tiny media/document fixtures committed under tests/fixtures/. * Requires ffmpeg on PATH (or FFMPEG_PATH) and qpdf (or QPDF_PATH). @@ -6,9 +8,7 @@ * node scripts/generate-test-fixtures.mjs */ import { createRequire } from "node:module"; -import { spawnSync } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join, dirname } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -38,17 +38,52 @@ function run(args) { } // 1s 64x64 mp4 with video + audio (h264 + aac; needed by extract-audio + mute-video) -run(["-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=8", - "-f", "lavfi", "-i", "sine=frequency=440:duration=1", - "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", - "-c:a", "aac", "-b:a", "32k", "-shortest", - join(mediaDir, "tiny.mp4")]); +run([ + "-f", + "lavfi", + "-i", + "testsrc=duration=1:size=64x64:rate=8", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "32k", + "-shortest", + join(mediaDir, "tiny.mp4"), +]); // 1s sine mp3 -run(["-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:a", "libmp3lame", "-b:a", "32k", - join(mediaDir, "tiny.mp3")]); +run([ + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:a", + "libmp3lame", + "-b:a", + "32k", + join(mediaDir, "tiny.mp3"), +]); // 1s sine wav -run(["-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:a", "pcm_s16le", "-ar", "8000", - join(mediaDir, "tiny.wav")]); +run([ + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:a", + "pcm_s16le", + "-ar", + "8000", + join(mediaDir, "tiny.wav"), +]); // Minimal OOXML/EPUB containers via archiver (resolved from apps/api). const apiRequire = createRequire(join(root, "apps/api/package.json")); @@ -108,7 +143,11 @@ await writeZip(join(docsDir, "tiny.xlsx"), { // EPUB requires the mimetype entry FIRST and STORED (uncompressed). await writeZip( join(docsDir, "tiny.epub"), - { "META-INF/container.xml": epubContainer, "OEBPS/content.opf": epubOpf, "OEBPS/chapter.xhtml": epubChapter }, + { + "META-INF/container.xml": epubContainer, + "OEBPS/content.opf": epubOpf, + "OEBPS/chapter.xhtml": epubChapter, + }, { name: "mimetype", content: "application/epub+zip" }, ); diff --git a/tests/e2e-pg-create-db.cjs b/tests/e2e-pg-create-db.cjs index da1a24f7..848a6ba6 100644 --- a/tests/e2e-pg-create-db.cjs +++ b/tests/e2e-pg-create-db.cjs @@ -16,8 +16,7 @@ const apiRequire = createRequire(join(process.cwd(), "apps/api/package.json")); const pg = apiRequire("pg"); const baseUrl = - process.env.E2E_PG_BASE_URL || - "postgres://snapotter:snapotter@localhost:5432/snapotter"; + process.env.E2E_PG_BASE_URL || "postgres://snapotter:snapotter@localhost:5432/snapotter"; const dbName = process.argv[2]; if (!dbName) { diff --git a/tests/helpers/enterprise-mock.ts b/tests/helpers/enterprise-mock.ts new file mode 100644 index 00000000..2f618db7 --- /dev/null +++ b/tests/helpers/enterprise-mock.ts @@ -0,0 +1,31 @@ +import type { EnterpriseFeature } from "@snapotter/enterprise"; +import { vi } from "vitest"; + +export function mockEnterpriseFeatures(features: EnterpriseFeature[]) { + vi.doMock("@snapotter/enterprise", () => ({ + isFeatureEnabled: (feature: string) => features.includes(feature as EnterpriseFeature), + getActiveLicense: () => ({ + org: "test-org", + plan: "enterprise" as const, + features, + seats: 100, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + issuedAt: new Date().toISOString(), + }), + initEnterprise: vi.fn(), + loadS3Storage: vi.fn(), + ENTERPRISE_FEATURES: features, + PLAN_FEATURES: { team: [], enterprise: features }, + })); +} + +export function mockNoEnterprise() { + vi.doMock("@snapotter/enterprise", () => ({ + isFeatureEnabled: () => false, + getActiveLicense: () => null, + initEnterprise: vi.fn(), + loadS3Storage: vi.fn(), + ENTERPRISE_FEATURES: [], + PLAN_FEATURES: { team: [], enterprise: [] }, + })); +} diff --git a/tests/integration/audit-export.test.ts b/tests/integration/audit-export.test.ts new file mode 100644 index 00000000..46a16d85 --- /dev/null +++ b/tests/integration/audit-export.test.ts @@ -0,0 +1,79 @@ +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("audit export", () => { + it("returns 403 without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/audit/export?format=json", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.error).toContain("enterprise"); + }); + + it("returns 403 for CSV format without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/audit/export?format=csv", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(403); + }); + + it("returns 401 without auth", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/audit/export?format=json", + }); + expect(res.statusCode).toBe(401); + }); + + it("returns 403 for non-admin user", async () => { + // Create a regular user + await testApp.app.inject({ + method: "POST", + url: "/api/auth/register", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { + username: "auditexportuser", + password: "TestPass1", + role: "user", + }, + }); + await db + .update(schema.users) + .set({ mustChangePassword: false }) + .where(eq(schema.users.username, "auditexportuser")); + + const loginRes = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "auditexportuser", password: "TestPass1" }, + }); + const userToken = JSON.parse(loginRes.body).token; + + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/audit/export?format=json", + headers: { authorization: `Bearer ${userToken}` }, + }); + // Regular users lack audit:read, so they get 403 before the enterprise check + expect(res.statusCode).toBe(403); + }); +}); diff --git a/tests/integration/audit-tool-operations.test.ts b/tests/integration/audit-tool-operations.test.ts new file mode 100644 index 00000000..fdcc6ed4 --- /dev/null +++ b/tests/integration/audit-tool-operations.test.ts @@ -0,0 +1,109 @@ +/** + * Integration tests for TOOL_EXECUTED audit logging. + * + * Verifies that the createToolRoute factory emits audit entries when the + * `auditToolOperations` admin setting is enabled and stays silent when it + * is disabled (the default). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const PNG = readFileSync(join(__dirname, "..", "fixtures", "test-1x1.png")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +async function setSetting(key: string, value: string): Promise { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { [key]: value }, + }); + expect(res.statusCode).toBe(200); +} + +async function fetchAuditLog(action: string): Promise<{ entries: any[]; total: number }> { + const res = await testApp.app.inject({ + method: "GET", + url: `/api/v1/audit-log?action=${action}`, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + return JSON.parse(res.body); +} + +async function processResize(): Promise { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ width: 1 }) }, + ]); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + return res.statusCode; +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe("tool operation audit logging", () => { + it("does not log TOOL_EXECUTED when auditToolOperations is disabled", async () => { + await setSetting("auditToolOperations", "false"); + + await processResize(); + + // Small delay to ensure fire-and-forget audit would have landed + await new Promise((r) => setTimeout(r, 200)); + + const body = await fetchAuditLog("TOOL_EXECUTED"); + expect(body.total).toBe(0); + }); + + it("logs TOOL_EXECUTED when auditToolOperations is enabled", async () => { + await setSetting("auditToolOperations", "true"); + + const statusCode = await processResize(); + expect(statusCode).toBe(200); + + // Small delay for the fire-and-forget audit write to complete + await new Promise((r) => setTimeout(r, 500)); + + const body = await fetchAuditLog("TOOL_EXECUTED"); + expect(body.total).toBeGreaterThanOrEqual(1); + + const entry = body.entries[0]; + expect(entry.action).toBe("TOOL_EXECUTED"); + expect(entry.details.toolId).toBe("resize"); + expect(entry.details.status).toBe("success"); + expect(typeof entry.details.durationMs).toBe("number"); + expect(entry.details.inputFileCount).toBe(1); + expect(typeof entry.details.totalInputSize).toBe("number"); + expect(entry.details.totalInputSize).toBeGreaterThan(0); + }); +}); diff --git a/tests/integration/gdpr-lifecycle.test.ts b/tests/integration/gdpr-lifecycle.test.ts new file mode 100644 index 00000000..969a98ed --- /dev/null +++ b/tests/integration/gdpr-lifecycle.test.ts @@ -0,0 +1,163 @@ +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("GDPR data export", () => { + it("returns 403 for POST without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/enterprise/users/some-id/export", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.error).toContain("enterprise"); + }); + + it("returns 403 for GET without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/users/some-id/export/some-job-id", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.error).toContain("enterprise"); + }); + + it("returns 401 for POST without auth", async () => { + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/enterprise/users/some-id/export", + }); + expect(res.statusCode).toBe(401); + }); + + it("returns 401 for GET without auth", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/users/some-id/export/some-job-id", + }); + expect(res.statusCode).toBe(401); + }); + + it("returns 401 for DELETE purge without auth", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(401); + }); + + it("returns 403 for non-admin user", async () => { + // Create a regular user + await testApp.app.inject({ + method: "POST", + url: "/api/auth/register", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { + username: "gdprexportuser", + password: "TestPass1", + role: "user", + }, + }); + await db + .update(schema.users) + .set({ mustChangePassword: false }) + .where(eq(schema.users.username, "gdprexportuser")); + + const loginRes = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "gdprexportuser", password: "TestPass1" }, + }); + const userToken = JSON.parse(loginRes.body).token; + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/enterprise/users/some-id/export", + headers: { authorization: `Bearer ${userToken}` }, + }); + // Regular users lack compliance:manage, so they get 403 before the enterprise check + expect(res.statusCode).toBe(403); + }); +}); + +describe("GDPR data purge", () => { + it("returns 403 without enterprise license for user purge", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.error).toContain("enterprise"); + }); + + it("returns 403 without enterprise license for team purge", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/teams/some-id/purge", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.error).toContain("enterprise"); + }); + + it("requires confirmation body for purge", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + headers: { authorization: `Bearer ${adminToken}` }, + payload: {}, + }); + // Should fail validation (400 or 403 depending on feature check order) + expect([400, 403]).toContain(res.statusCode); + }); + + it("rejects purge with confirm: false", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { confirm: false }, + }); + expect([400, 403]).toContain(res.statusCode); + }); + + it("returns 401 for user purge without auth", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(401); + }); + + it("returns 401 for team purge without auth", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/teams/some-id/purge", + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/legal-hold.test.ts b/tests/integration/legal-hold.test.ts new file mode 100644 index 00000000..46d91ff4 --- /dev/null +++ b/tests/integration/legal-hold.test.ts @@ -0,0 +1,91 @@ +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("legal hold", () => { + it("returns 403 for PUT without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/enterprise/legal-hold", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { targetType: "user", targetId: "some-id", hold: true }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.error).toContain("enterprise"); + }); + + it("returns 403 for GET without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/legal-hold", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.error).toContain("enterprise"); + }); + + it("returns 401 without auth", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/legal-hold", + }); + expect(res.statusCode).toBe(401); + }); + + it("returns 401 for PUT without auth", async () => { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/enterprise/legal-hold", + payload: { targetType: "user", targetId: "some-id", hold: true }, + }); + expect(res.statusCode).toBe(401); + }); + + it("returns 403 for non-admin user", async () => { + // Create a regular user + await testApp.app.inject({ + method: "POST", + url: "/api/auth/register", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { + username: "legalholduser", + password: "TestPass1", + role: "user", + }, + }); + await db + .update(schema.users) + .set({ mustChangePassword: false }) + .where(eq(schema.users.username, "legalholduser")); + + const loginRes = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "legalholduser", password: "TestPass1" }, + }); + const userToken = JSON.parse(loginRes.body).token; + + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/legal-hold", + headers: { authorization: `Bearer ${userToken}` }, + }); + // Regular users lack compliance:manage, so they get 403 before the enterprise check + expect(res.statusCode).toBe(403); + }); +}); diff --git a/tests/integration/scim.test.ts b/tests/integration/scim.test.ts new file mode 100644 index 00000000..0cd36cc3 --- /dev/null +++ b/tests/integration/scim.test.ts @@ -0,0 +1,159 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { hashPassword } from "../../apps/api/src/plugins/auth.js"; +import { buildTestApp, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +const SCIM_TOKEN = "test-scim-token-abc123"; + +beforeAll(async () => { + testApp = await buildTestApp(); + + // Set up a SCIM token hash in the settings table + const tokenHash = await hashPassword(SCIM_TOKEN); + await db + .insert(schema.settings) + .values({ key: "scim_token_hash", value: tokenHash }) + .onConflictDoNothing(); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("SCIM 2.0 provisioning", () => { + // ── Discovery (no auth required) ─────────────────────────────── + + describe("discovery endpoints", () => { + it("returns ServiceProviderConfig", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/scim/v2/ServiceProviderConfig", + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.schemas).toContain("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"); + expect(body.patch.supported).toBe(true); + expect(body.filter.supported).toBe(true); + }); + + it("returns Schemas", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/scim/v2/Schemas", + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.totalResults).toBe(2); + expect(body.Resources).toHaveLength(2); + const schemaIds = body.Resources.map((r: { id: string }) => r.id); + expect(schemaIds).toContain("urn:ietf:params:scim:schemas:core:2.0:User"); + expect(schemaIds).toContain("urn:ietf:params:scim:schemas:core:2.0:Group"); + }); + + it("returns ResourceTypes", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/scim/v2/ResourceTypes", + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.totalResults).toBe(2); + const names = body.Resources.map((r: { name: string }) => r.name); + expect(names).toContain("User"); + expect(names).toContain("Group"); + }); + }); + + // ── Auth ─────────────────────────────────────────────────────── + + describe("SCIM auth", () => { + it("returns 401 for user operations without token", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/scim/v2/Users", + }); + expect(res.statusCode).toBe(401); + const body = JSON.parse(res.body); + expect(body.schemas).toContain("urn:ietf:params:scim:api:messages:2.0:Error"); + }); + + it("returns 401 with invalid token", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/scim/v2/Users", + headers: { authorization: "Bearer wrong-token" }, + }); + expect(res.statusCode).toBe(401); + }); + + it("returns 401 for group operations without token", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/scim/v2/Groups", + }); + expect(res.statusCode).toBe(401); + }); + }); + + // ── Enterprise gate ──────────────────────────────────────────── + // Without a valid enterprise license, SCIM operations return 403. + + describe("enterprise feature gate", () => { + it("returns 403 for Users list without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/scim/v2/Users", + headers: { authorization: `Bearer ${SCIM_TOKEN}` }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.detail).toContain("enterprise"); + }); + + it("returns 403 for Groups list without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/scim/v2/Groups", + headers: { authorization: `Bearer ${SCIM_TOKEN}` }, + }); + expect(res.statusCode).toBe(403); + }); + + it("returns 403 for POST Users without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/scim/v2/Users", + headers: { authorization: `Bearer ${SCIM_TOKEN}` }, + payload: { userName: "scim-test-user", active: true }, + }); + expect(res.statusCode).toBe(403); + }); + + it("returns 403 for POST Groups without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/scim/v2/Groups", + headers: { authorization: `Bearer ${SCIM_TOKEN}` }, + payload: { displayName: "scim-test-group" }, + }); + expect(res.statusCode).toBe(403); + }); + }); + + // ── SCIM error format ────────────────────────────────────────── + + describe("SCIM error format", () => { + it("returns proper SCIM error schema on 401", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/scim/v2/Users", + headers: { authorization: "Bearer bad" }, + }); + const body = JSON.parse(res.body); + expect(body.schemas).toEqual(["urn:ietf:params:scim:api:messages:2.0:Error"]); + expect(body.status).toBe(401); + expect(typeof body.detail).toBe("string"); + }); + }); +}); diff --git a/tests/integration/siem-forwarding.test.ts b/tests/integration/siem-forwarding.test.ts new file mode 100644 index 00000000..de4df44b --- /dev/null +++ b/tests/integration/siem-forwarding.test.ts @@ -0,0 +1,48 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("SIEM config", () => { + it("returns 403 for GET without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/siem/config", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(403); + }); + + it("returns 403 for PUT without enterprise license", async () => { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/enterprise/siem/config", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { + webhookUrl: "https://siem.example.com/input", + authHeader: "Splunk test", + flushIntervalSeconds: 30, + enabled: true, + }, + }); + expect(res.statusCode).toBe(403); + }); + + it("returns 401 without auth", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/enterprise/siem/config", + }); + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index 59922da1..cc0d6bdd 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -46,7 +46,11 @@ import { ensureDefaultAdmin, requireAuth, } from "../../apps/api/src/plugins/auth.js"; +import { registerIpAllowlist } from "../../apps/api/src/plugins/ip-allowlist.js"; +import { registerMfa } from "../../apps/api/src/plugins/mfa.js"; import { oidcRoutes } from "../../apps/api/src/plugins/oidc.js"; +import { registerPerUserRateLimit } from "../../apps/api/src/plugins/per-user-rate-limit.js"; +import { registerSaml } from "../../apps/api/src/plugins/saml.js"; import { registerUpload } from "../../apps/api/src/plugins/upload.js"; import { adminOpsRoutes } from "../../apps/api/src/routes/admin-ops.js"; import { analyticsRoutes } from "../../apps/api/src/routes/analytics.js"; @@ -54,6 +58,7 @@ import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js"; import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js"; import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js"; import { docsRoutes } from "../../apps/api/src/routes/docs.js"; +import { registerEnterpriseRoutes } from "../../apps/api/src/routes/enterprise/index.js"; import { registerFetchUrlsRoute } from "../../apps/api/src/routes/fetch-urls.js"; import { fileRoutes } from "../../apps/api/src/routes/files.js"; import { registerMemeTemplates } from "../../apps/api/src/routes/meme-templates.js"; @@ -134,15 +139,43 @@ export async function buildTestApp(): Promise { // Cookie support await app.register(cookie, { secret: "test-cookie-secret", hook: "onRequest" }); + // IP allowlist (enterprise -- guards internally, returns early if not licensed) + try { + await registerIpAllowlist(app); + } catch { + // Enterprise package not available in test env + } + // Auth middleware (must be registered before routes) await authMiddleware(app); + // Per-user rate limiting (after auth so request.user is populated) + try { + await registerPerUserRateLimit(app); + } catch { + // Redis may not be fully available in all test scenarios + } + // Auth routes await authRoutes(app); // OIDC routes await oidcRoutes(app); + // SAML routes (enterprise -- guards internally, returns early if not licensed) + try { + await registerSaml(app); + } catch { + // Enterprise package not available in test env + } + + // MFA routes (TOTP enrollment, verification, disable) + try { + await registerMfa(app); + } catch { + // MFA dependencies may not be available in test env + } + // File upload/download routes await fileRoutes(app); @@ -185,6 +218,9 @@ export async function buildTestApp(): Promise { // Admin ops routes (runtime log level, Prometheus metrics) await adminOpsRoutes(app); + // Enterprise routes (license-gated features) + await registerEnterpriseRoutes(app); + // Analytics routes await analyticsRoutes(app); @@ -228,6 +264,10 @@ export async function buildTestApp(): Promise { config.oidcProviderName = env.OIDC_PROVIDER_NAME || null; config.oidcLoginUrl = "/api/auth/oidc/login"; } + config.samlEnabled = false; + config.samlProviderName = ""; + config.samlLoginUrl = ""; + config.ssoEnforced = false; return config; }); diff --git a/tests/unit/api/audit-archival.test.ts b/tests/unit/api/audit-archival.test.ts new file mode 100644 index 00000000..af059253 --- /dev/null +++ b/tests/unit/api/audit-archival.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +describe("audit archival state machine", () => { + const validTransitions: Record = { + PENDING: "EXPORTING", + EXPORTING: "EXPORTED", + EXPORTED: "PURGING", + PURGING: "COMPLETE", + }; + + it("state transitions are valid", () => { + for (const [from, to] of Object.entries(validTransitions)) { + expect(from).toBeTruthy(); + expect(to).toBeDefined(); + } + }); + + it("covers all non-terminal states", () => { + const states = ["PENDING", "EXPORTING", "EXPORTED", "PURGING", "COMPLETE"]; + const nonTerminal = states.filter((s) => s !== "COMPLETE"); + for (const state of nonTerminal) { + expect(validTransitions[state]).toBeDefined(); + } + }); + + it("COMPLETE is terminal (no outgoing transition)", () => { + expect(validTransitions.COMPLETE).toBeUndefined(); + }); + + it("has no cycles in the transition graph", () => { + const visited = new Set(); + let current = "PENDING"; + while (current && !visited.has(current)) { + visited.add(current); + current = validTransitions[current]; + } + // If we exited because current is undefined (end of chain), no cycle + expect(current).toBeUndefined(); + }); +}); diff --git a/tests/unit/api/audit-integrity.test.ts b/tests/unit/api/audit-integrity.test.ts new file mode 100644 index 00000000..5b9b71df --- /dev/null +++ b/tests/unit/api/audit-integrity.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalize, + computeHmac, + verifyHmac, +} from "../../../apps/api/src/lib/audit-integrity.js"; + +describe("audit integrity", () => { + const testKey = Buffer.from("a".repeat(64), "hex"); + + it("canonicalizes with sorted keys", () => { + const input = { z: 1, a: 2, m: { b: 3, a: 4 } }; + const result = canonicalize(input); + expect(result).toBe('{"a":2,"m":{"a":4,"b":3},"z":1}'); + }); + + it("canonicalizes with null values included", () => { + const input = { a: null, b: "test" }; + expect(canonicalize(input)).toBe('{"a":null,"b":"test"}'); + }); + + it("canonicalizes arrays", () => { + const input = { items: [3, 1, 2] }; + expect(canonicalize(input)).toBe('{"items":[3,1,2]}'); + }); + + it("computes deterministic HMAC", () => { + const data = { event: "LOGIN_SUCCESS", actorId: "u1" }; + const hmac1 = computeHmac(data, testKey); + const hmac2 = computeHmac(data, testKey); + expect(hmac1).toBe(hmac2); + }); + + it("computes and verifies HMAC", () => { + const data = { event: "LOGIN_SUCCESS", actorId: "u1" }; + const hmac = computeHmac(data, testKey); + expect(verifyHmac(data, hmac, testKey)).toBe(true); + }); + + it("detects tampering", () => { + const data = { event: "LOGIN_SUCCESS", actorId: "u1" }; + const hmac = computeHmac(data, testKey); + const tampered = { ...data, actorId: "u2" }; + expect(verifyHmac(tampered, hmac, testKey)).toBe(false); + }); + + it("key ordering does not affect HMAC", () => { + const data1 = { b: 2, a: 1 }; + const data2 = { a: 1, b: 2 }; + expect(computeHmac(data1, testKey)).toBe(computeHmac(data2, testKey)); + }); +}); diff --git a/tests/unit/api/effective-permissions.test.ts b/tests/unit/api/effective-permissions.test.ts index 1b3c14d4..5192bd35 100644 --- a/tests/unit/api/effective-permissions.test.ts +++ b/tests/unit/api/effective-permissions.test.ts @@ -192,8 +192,8 @@ describe("hasEffectivePermission", () => { describe("getPermissions", () => { describe("exact counts for built-in roles", () => { - it("admin has exactly 14 permissions", async () => { - expect(await getPermissions("admin")).toHaveLength(14); + it("admin has exactly 17 permissions", async () => { + expect(await getPermissions("admin")).toHaveLength(17); }); it("editor has exactly 7 permissions", async () => { diff --git a/tests/unit/api/encryption.test.ts b/tests/unit/api/encryption.test.ts new file mode 100644 index 00000000..d4766d05 --- /dev/null +++ b/tests/unit/api/encryption.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + decrypt, + deriveAuditHmacKey, + encrypt, + isEncrypted, +} from "../../../apps/api/src/lib/encryption.js"; + +describe("encryption", () => { + const testKey = "a".repeat(64); // 32 bytes hex-encoded + + it("encrypts and decrypts a value", async () => { + const plaintext = "my-secret-oidc-client-secret"; + const encrypted = await encrypt(plaintext, testKey); + expect(encrypted).not.toBe(plaintext); + expect(isEncrypted(encrypted)).toBe(true); + const decrypted = await decrypt(encrypted, testKey); + expect(decrypted).toBe(plaintext); + }); + + it("produces different ciphertext for same plaintext (random IV)", async () => { + const plaintext = "same-value"; + const a = await encrypt(plaintext, testKey); + const b = await encrypt(plaintext, testKey); + expect(a).not.toBe(b); + }); + + it("isEncrypted returns false for plaintext", () => { + expect(isEncrypted("just-a-normal-value")).toBe(false); + expect(isEncrypted("")).toBe(false); + }); + + it("decrypt returns null for wrong key", async () => { + const encrypted = await encrypt("secret", testKey); + const wrongKey = "b".repeat(64); + const result = await decrypt(encrypted, wrongKey); + expect(result).toBeNull(); + }); + + it("decrypt tries previous key on failure", async () => { + const oldKey = "c".repeat(64); + const newKey = "d".repeat(64); + const encrypted = await encrypt("secret", oldKey); + const result = await decrypt(encrypted, newKey, oldKey); + expect(result).toBe("secret"); + }); + + it("decrypt passes through non-encrypted values", async () => { + const result = await decrypt("plain-text-value", testKey); + expect(result).toBe("plain-text-value"); + }); + + it("deriveAuditHmacKey produces a 32-byte buffer", async () => { + const key = await deriveAuditHmacKey(testKey); + expect(key).toBeInstanceOf(Buffer); + expect(key.length).toBe(32); + }); +}); diff --git a/tests/unit/api/enterprise-flags.test.ts b/tests/unit/api/enterprise-flags.test.ts new file mode 100644 index 00000000..9aed611a --- /dev/null +++ b/tests/unit/api/enterprise-flags.test.ts @@ -0,0 +1,38 @@ +import { ENTERPRISE_FEATURES, PLAN_FEATURES } from "@snapotter/enterprise"; +import { describe, expect, it } from "vitest"; + +describe("enterprise feature flags", () => { + it("includes all Phase 1 flags", () => { + expect(ENTERPRISE_FEATURES).toContain("siem_forwarding"); + expect(ENTERPRISE_FEATURES).toContain("tamper_resistant_audit"); + }); + + it("includes all Phase 2-4 flags", () => { + expect(ENTERPRISE_FEATURES).toContain("legal_hold"); + expect(ENTERPRISE_FEATURES).toContain("gdpr_lifecycle"); + expect(ENTERPRISE_FEATURES).toContain("admin_alerts"); + }); + + it("team plan includes operational features", () => { + expect(PLAN_FEATURES.team).toContain("siem_forwarding"); + expect(PLAN_FEATURES.team).toContain("audit_export"); + expect(PLAN_FEATURES.team).toContain("upgrade_management"); + expect(PLAN_FEATURES.team).toContain("admin_alerts"); + }); + + it("team plan does NOT include compliance features", () => { + expect(PLAN_FEATURES.team).not.toContain("tamper_resistant_audit"); + expect(PLAN_FEATURES.team).not.toContain("legal_hold"); + expect(PLAN_FEATURES.team).not.toContain("gdpr_lifecycle"); + }); + + it("enterprise plan includes everything", () => { + for (const feature of ENTERPRISE_FEATURES) { + expect(PLAN_FEATURES.enterprise).toContain(feature); + } + }); + + it("has exactly 18 features total", () => { + expect(ENTERPRISE_FEATURES).toHaveLength(18); + }); +}); diff --git a/tests/unit/api/enterprise-mock.test.ts b/tests/unit/api/enterprise-mock.test.ts new file mode 100644 index 00000000..2876e09a --- /dev/null +++ b/tests/unit/api/enterprise-mock.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("enterprise mock", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("mocks feature enabled", async () => { + const { mockEnterpriseFeatures } = await import("../../helpers/enterprise-mock.js"); + mockEnterpriseFeatures(["audit_export", "siem_forwarding"]); + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + expect(isFeatureEnabled("audit_export")).toBe(true); + expect(isFeatureEnabled("siem_forwarding")).toBe(true); + expect(isFeatureEnabled("scim")).toBe(false); + }); + + it("mocks no enterprise", async () => { + const { mockNoEnterprise } = await import("../../helpers/enterprise-mock.js"); + mockNoEnterprise(); + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + expect(isFeatureEnabled("audit_export")).toBe(false); + }); + + it("returns license payload with correct shape", async () => { + const { mockEnterpriseFeatures } = await import("../../helpers/enterprise-mock.js"); + mockEnterpriseFeatures(["s3_storage", "mfa"]); + const { getActiveLicense } = await import("@snapotter/enterprise"); + const license = getActiveLicense(); + expect(license).not.toBeNull(); + expect(license!.org).toBe("test-org"); + expect(license!.plan).toBe("enterprise"); + expect(license!.features).toEqual(["s3_storage", "mfa"]); + expect(license!.seats).toBe(100); + expect(new Date(license!.expiresAt).getTime()).toBeGreaterThan(Date.now()); + }); + + it("returns null license when no enterprise", async () => { + const { mockNoEnterprise } = await import("../../helpers/enterprise-mock.js"); + mockNoEnterprise(); + const { getActiveLicense } = await import("@snapotter/enterprise"); + expect(getActiveLicense()).toBeNull(); + }); +}); diff --git a/tests/unit/api/external-auth-resolver.test.ts b/tests/unit/api/external-auth-resolver.test.ts new file mode 100644 index 00000000..19d3e815 --- /dev/null +++ b/tests/unit/api/external-auth-resolver.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +// ── Pure function tests (no DB required) ───────────────────────── + +describe("external auth resolver", () => { + it("module exports resolveExternalUser", async () => { + const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js"); + expect(typeof mod.resolveExternalUser).toBe("function"); + }); + + it("module exports sanitizeUsername", async () => { + const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js"); + expect(typeof mod.sanitizeUsername).toBe("function"); + }); + + it("module exports findUniqueUsername", async () => { + const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js"); + expect(typeof mod.findUniqueUsername).toBe("function"); + }); +}); + +describe("sanitizeUsername", () => { + let sanitizeUsername: (raw: string) => string; + + beforeAll(async () => { + const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js"); + sanitizeUsername = mod.sanitizeUsername; + }); + + it("lowercases input", () => { + expect(sanitizeUsername("JohnDoe")).toBe("johndoe"); + }); + + it("replaces non-alphanumeric characters with underscores", () => { + expect(sanitizeUsername("john doe!")).toBe("john_doe"); + }); + + it("collapses multiple underscores", () => { + expect(sanitizeUsername("john___doe")).toBe("john_doe"); + }); + + it("strips leading and trailing separators", () => { + expect(sanitizeUsername("_john_")).toBe("john"); + expect(sanitizeUsername(".john.")).toBe("john"); + expect(sanitizeUsername("-john-")).toBe("john"); + }); + + it("truncates to 46 characters", () => { + const long = "a".repeat(60); + expect(sanitizeUsername(long).length).toBe(46); + }); + + it("pads short usernames to 3 characters", () => { + expect(sanitizeUsername("ab").length).toBe(3); + expect(sanitizeUsername("ab")).toBe("ab_"); + }); + + it("preserves dots and hyphens", () => { + expect(sanitizeUsername("john.doe")).toBe("john.doe"); + expect(sanitizeUsername("john-doe")).toBe("john-doe"); + }); + + it("handles email addresses as input", () => { + expect(sanitizeUsername("user@example.com")).toBe("user_example.com"); + }); + + it("handles empty-after-strip edge case", () => { + // All characters stripped, then padded + const result = sanitizeUsername("___"); + expect(result.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/tests/unit/api/ip-allowlist.test.ts b/tests/unit/api/ip-allowlist.test.ts new file mode 100644 index 00000000..a2bf6ac7 --- /dev/null +++ b/tests/unit/api/ip-allowlist.test.ts @@ -0,0 +1,193 @@ +import type { BlockList } from "node:net"; +import { describe, expect, it } from "vitest"; +import { + buildBlockList, + EXEMPT_PATHS, + isExemptPath, + isIpAllowed, + isValidCidr, +} from "../../../apps/api/src/plugins/ip-allowlist.js"; + +/** Helper that asserts buildBlockList returned a non-null value. */ +function mustBuild(cidrs: string[]): BlockList { + const bl = buildBlockList(cidrs); + if (!bl) throw new Error("Expected non-null BlockList"); + return bl; +} + +describe("IP allowlist", () => { + // ── buildBlockList ─────────────────────────────────────────────── + describe("buildBlockList", () => { + it("returns null for an empty array", () => { + expect(buildBlockList([])).toBeNull(); + }); + + it("builds a list from IPv4 CIDRs", () => { + const bl = buildBlockList(["10.0.0.0/8", "192.168.1.0/24"]); + expect(bl).not.toBeNull(); + }); + + it("builds a list from bare IPv4 addresses", () => { + const bl = buildBlockList(["1.2.3.4"]); + expect(bl).not.toBeNull(); + }); + + it("builds a list from IPv6 CIDRs", () => { + const bl = buildBlockList(["2001:db8::/32"]); + expect(bl).not.toBeNull(); + }); + + it("skips invalid entries without throwing", () => { + const bl = buildBlockList(["not-a-cidr", "10.0.0.0/8"]); + expect(bl).not.toBeNull(); + }); + }); + + // ── isIpAllowed ────────────────────────────────────────────────── + describe("isIpAllowed", () => { + it("allows an IP inside a CIDR range", () => { + const bl = mustBuild(["10.0.0.0/8"]); + expect(isIpAllowed("10.1.2.3", bl)).toBe(true); + }); + + it("denies an IP outside all ranges", () => { + const bl = mustBuild(["10.0.0.0/8"]); + expect(isIpAllowed("192.168.1.1", bl)).toBe(false); + }); + + it("allows an exact single-address match", () => { + const bl = mustBuild(["1.2.3.4"]); + expect(isIpAllowed("1.2.3.4", bl)).toBe(true); + expect(isIpAllowed("1.2.3.5", bl)).toBe(false); + }); + + it("allows an IPv6 address inside a subnet", () => { + const bl = mustBuild(["2001:db8::/32"]); + expect(isIpAllowed("2001:db8::1", bl)).toBe(true); + expect(isIpAllowed("2001:db9::1", bl)).toBe(false); + }); + + it("handles IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)", () => { + const bl = mustBuild(["10.0.0.0/8"]); + expect(isIpAllowed("::ffff:10.1.2.3", bl)).toBe(true); + expect(isIpAllowed("::ffff:192.168.1.1", bl)).toBe(false); + }); + + it("handles multiple CIDR ranges", () => { + const bl = mustBuild(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]); + expect(isIpAllowed("10.0.0.1", bl)).toBe(true); + expect(isIpAllowed("172.20.1.1", bl)).toBe(true); + expect(isIpAllowed("192.168.99.1", bl)).toBe(true); + expect(isIpAllowed("8.8.8.8", bl)).toBe(false); + }); + + it("allows /32 single-host CIDR", () => { + const bl = mustBuild(["1.2.3.4/32"]); + expect(isIpAllowed("1.2.3.4", bl)).toBe(true); + expect(isIpAllowed("1.2.3.5", bl)).toBe(false); + }); + + it("allows /0 to match everything", () => { + const bl = mustBuild(["0.0.0.0/0"]); + expect(isIpAllowed("1.2.3.4", bl)).toBe(true); + expect(isIpAllowed("255.255.255.255", bl)).toBe(true); + }); + + it("allows 127.0.0.1 when loopback is listed", () => { + const bl = mustBuild(["127.0.0.0/8"]); + expect(isIpAllowed("127.0.0.1", bl)).toBe(true); + }); + }); + + // ── isValidCidr ────────────────────────────────────────────────── + describe("isValidCidr", () => { + it("accepts valid IPv4 CIDR", () => { + expect(isValidCidr("10.0.0.0/8")).toBe(true); + expect(isValidCidr("192.168.1.0/24")).toBe(true); + expect(isValidCidr("0.0.0.0/0")).toBe(true); + }); + + it("accepts valid bare IPv4 address", () => { + expect(isValidCidr("1.2.3.4")).toBe(true); + }); + + it("accepts valid IPv6 CIDR", () => { + expect(isValidCidr("2001:db8::/32")).toBe(true); + expect(isValidCidr("::1/128")).toBe(true); + }); + + it("accepts valid bare IPv6 address", () => { + expect(isValidCidr("::1")).toBe(true); + }); + + it("rejects invalid strings", () => { + expect(isValidCidr("not-an-ip")).toBe(false); + expect(isValidCidr("")).toBe(false); + }); + + it("rejects IPv4 prefix > 32", () => { + expect(isValidCidr("10.0.0.0/33")).toBe(false); + }); + + it("rejects IPv6 prefix > 128", () => { + expect(isValidCidr("::1/129")).toBe(false); + }); + + it("rejects negative prefix", () => { + expect(isValidCidr("10.0.0.0/-1")).toBe(false); + }); + }); + + // ── isExemptPath ───────────────────────────────────────────────── + describe("isExemptPath", () => { + it("exempts health endpoint", () => { + expect(isExemptPath("/api/v1/health")).toBe(true); + }); + + it("exempts readyz endpoint", () => { + expect(isExemptPath("/api/v1/readyz")).toBe(true); + }); + + it("exempts metrics endpoint", () => { + expect(isExemptPath("/api/v1/metrics")).toBe(true); + }); + + it("exempts SCIM paths", () => { + expect(isExemptPath("/api/v1/scim/Users")).toBe(true); + expect(isExemptPath("/api/v1/scim/Groups")).toBe(true); + }); + + it("exempts SAML callback", () => { + expect(isExemptPath("/api/auth/saml/callback")).toBe(true); + }); + + it("exempts OIDC callback", () => { + expect(isExemptPath("/api/auth/oidc/callback")).toBe(true); + }); + + it("does NOT exempt regular API paths", () => { + expect(isExemptPath("/api/v1/tools/crop")).toBe(false); + expect(isExemptPath("/api/v1/settings")).toBe(false); + expect(isExemptPath("/api/auth/login")).toBe(false); + }); + + it("does NOT exempt the root path", () => { + expect(isExemptPath("/")).toBe(false); + }); + }); + + // ── EXEMPT_PATHS constant ──────────────────────────────────────── + describe("EXEMPT_PATHS", () => { + it("includes all expected infrastructure paths", () => { + expect(EXEMPT_PATHS).toContain("/api/v1/health"); + expect(EXEMPT_PATHS).toContain("/api/v1/readyz"); + expect(EXEMPT_PATHS).toContain("/api/v1/metrics"); + }); + + it("includes IdP callback paths", () => { + expect(EXEMPT_PATHS).toContain("/api/auth/saml/callback"); + expect(EXEMPT_PATHS).toContain("/api/auth/oidc/callback"); + expect(EXEMPT_PATHS).toContain("/api/v1/scim/"); + }); + }); +}); diff --git a/tests/unit/api/mfa.test.ts b/tests/unit/api/mfa.test.ts new file mode 100644 index 00000000..24857863 --- /dev/null +++ b/tests/unit/api/mfa.test.ts @@ -0,0 +1,191 @@ +import * as OTPAuth from "otpauth"; +import { describe, expect, it } from "vitest"; +import { + createTotp, + hashRecoveryCodes, + isMfaRequiredForUser, + verifyRecoveryCode, + verifyTotpCode, +} from "../../../apps/api/src/plugins/mfa.js"; + +describe("MFA", () => { + describe("createTotp", () => { + it("generates a valid TOTP URI", () => { + const totp = createTotp("testuser"); + const uri = totp.toString(); + expect(uri).toContain("otpauth://totp/"); + expect(uri).toContain("SnapOtter"); + expect(uri).toContain("testuser"); + expect(uri).toContain("algorithm=SHA1"); + expect(uri).toContain("digits=6"); + expect(uri).toContain("period=30"); + }); + + it("generates a TOTP with a valid secret", () => { + const totp = createTotp("testuser"); + expect(totp.secret.base32).toMatch(/^[A-Z2-7]+=*$/); + // 20-byte secret = 32 base32 chars + expect(totp.secret.base32.length).toBe(32); + }); + + it("uses a provided secret when given", () => { + const knownSecret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + const totp = createTotp("testuser", knownSecret); + expect(totp.secret.base32).toBe(knownSecret); + }); + + it("generates unique secrets across calls", () => { + const a = createTotp("user1"); + const b = createTotp("user2"); + expect(a.secret.base32).not.toBe(b.secret.base32); + }); + }); + + describe("verifyTotpCode", () => { + it("verifies a correct TOTP code", () => { + const totp = createTotp("testuser"); + const secret = totp.secret.base32; + const code = totp.generate(); + expect(verifyTotpCode(secret, code)).toBe(true); + }); + + it("rejects an incorrect TOTP code", () => { + const totp = createTotp("testuser"); + const secret = totp.secret.base32; + expect(verifyTotpCode(secret, "000000")).toBe(false); + }); + + it("rejects an empty code", () => { + const totp = createTotp("testuser"); + const secret = totp.secret.base32; + expect(verifyTotpCode(secret, "")).toBe(false); + }); + + it("rejects a code from a different secret", () => { + const totp1 = createTotp("user1"); + const totp2 = createTotp("user2"); + const code = totp1.generate(); + expect(verifyTotpCode(totp2.secret.base32, code)).toBe(false); + }); + + it("accepts codes within the 1-step window", () => { + const secret = new OTPAuth.Secret({ size: 20 }); + const totp = new OTPAuth.TOTP({ + issuer: "SnapOtter", + label: "test", + algorithm: "SHA1", + digits: 6, + period: 30, + secret, + }); + + // Generate code for the current period + const code = totp.generate(); + expect(verifyTotpCode(secret.base32, code)).toBe(true); + }); + }); + + describe("hashRecoveryCodes", () => { + it("hashes recovery codes into comma-separated SHA-256 hashes", () => { + const codes = ["abcd1234", "efgh5678"]; + const result = hashRecoveryCodes(codes); + const parts = result.split(","); + expect(parts).toHaveLength(2); + // Each hash should be 64 hex chars (256 bits) + for (const hash of parts) { + expect(hash).toMatch(/^[0-9a-f]{64}$/); + } + }); + + it("produces deterministic hashes", () => { + const codes = ["code1", "code2", "code3"]; + const a = hashRecoveryCodes(codes); + const b = hashRecoveryCodes(codes); + expect(a).toBe(b); + }); + + it("handles a single code", () => { + const result = hashRecoveryCodes(["onlycode"]); + expect(result).not.toContain(","); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + }); + + describe("verifyRecoveryCode", () => { + it("verifies a valid recovery code", () => { + const codes = ["aaaa1111", "bbbb2222", "cccc3333"]; + const hashList = hashRecoveryCodes(codes); + const result = verifyRecoveryCode("bbbb2222", hashList); + expect(result.valid).toBe(true); + }); + + it("rejects an invalid recovery code", () => { + const codes = ["aaaa1111", "bbbb2222"]; + const hashList = hashRecoveryCodes(codes); + const result = verifyRecoveryCode("invalid0", hashList); + expect(result.valid).toBe(false); + expect(result.remaining).toBe(hashList); + }); + + it("consumes a recovery code on use", () => { + const codes = ["aaaa1111", "bbbb2222", "cccc3333"]; + const hashList = hashRecoveryCodes(codes); + const result = verifyRecoveryCode("bbbb2222", hashList); + expect(result.valid).toBe(true); + // Remaining should have 2 hashes + const remaining = result.remaining.split(","); + expect(remaining).toHaveLength(2); + // The used code should no longer verify + const secondTry = verifyRecoveryCode("bbbb2222", result.remaining); + expect(secondTry.valid).toBe(false); + }); + + it("returns empty remaining when last code is used", () => { + const codes = ["onlycode"]; + const hashList = hashRecoveryCodes(codes); + const result = verifyRecoveryCode("onlycode", hashList); + expect(result.valid).toBe(true); + expect(result.remaining).toBe(""); + }); + + it("preserves other codes when one is consumed", () => { + const codes = ["first000", "second00", "third000"]; + const hashList = hashRecoveryCodes(codes); + + // Use the first code + const r1 = verifyRecoveryCode("first000", hashList); + expect(r1.valid).toBe(true); + + // Second and third should still work + const r2 = verifyRecoveryCode("second00", r1.remaining); + expect(r2.valid).toBe(true); + + const r3 = verifyRecoveryCode("third000", r2.remaining); + expect(r3.valid).toBe(true); + expect(r3.remaining).toBe(""); + }); + }); + + describe("isMfaRequiredForUser", () => { + it("returns false for optional policy", () => { + expect(isMfaRequiredForUser("optional", "admin")).toBe(false); + expect(isMfaRequiredForUser("optional", "editor")).toBe(false); + expect(isMfaRequiredForUser("optional", "user")).toBe(false); + }); + + it("returns true for required policy regardless of role", () => { + expect(isMfaRequiredForUser("required", "admin")).toBe(true); + expect(isMfaRequiredForUser("required", "editor")).toBe(true); + expect(isMfaRequiredForUser("required", "user")).toBe(true); + }); + + it("returns true for admins_only policy when role is admin", () => { + expect(isMfaRequiredForUser("admins_only", "admin")).toBe(true); + }); + + it("returns false for admins_only policy when role is not admin", () => { + expect(isMfaRequiredForUser("admins_only", "editor")).toBe(false); + expect(isMfaRequiredForUser("admins_only", "user")).toBe(false); + }); + }); +}); diff --git a/tests/unit/api/permissions.test.ts b/tests/unit/api/permissions.test.ts index 76eef3af..318523b1 100644 --- a/tests/unit/api/permissions.test.ts +++ b/tests/unit/api/permissions.test.ts @@ -25,9 +25,9 @@ import { getPermissions, hasPermission } from "../../../apps/api/src/permissions describe("permissions", () => { describe("getPermissions", () => { - it("returns all 14 permissions for admin", async () => { + it("returns all 17 permissions for admin", async () => { const perms = await getPermissions("admin"); - expect(perms).toHaveLength(14); + expect(perms).toHaveLength(17); expect(perms).toContain("tools:use"); expect(perms).toContain("files:own"); expect(perms).toContain("files:all"); @@ -42,6 +42,9 @@ describe("permissions", () => { expect(perms).toContain("features:manage"); expect(perms).toContain("system:health"); expect(perms).toContain("audit:read"); + expect(perms).toContain("compliance:manage"); + expect(perms).toContain("webhooks:manage"); + expect(perms).toContain("security:manage"); }); it("returns only basic permissions for user role", async () => { diff --git a/tests/unit/api/rbac-enforcement.test.ts b/tests/unit/api/rbac-enforcement.test.ts index 8246535f..75135632 100644 --- a/tests/unit/api/rbac-enforcement.test.ts +++ b/tests/unit/api/rbac-enforcement.test.ts @@ -16,7 +16,7 @@ vi.mock("../../../apps/api/src/plugins/auth.js", () => ({ import { getPermissions, hasPermission } from "../../../apps/api/src/permissions.js"; describe("role permissions", () => { - it("admin has all 14 permissions", async () => { + it("admin has all 17 permissions", async () => { const perms = await getPermissions("admin"); expect(perms).toContain("tools:use"); expect(perms).toContain("files:all"); @@ -24,7 +24,10 @@ describe("role permissions", () => { expect(perms).toContain("features:manage"); expect(perms).toContain("system:health"); expect(perms).toContain("audit:read"); - expect(perms.length).toBe(14); + expect(perms).toContain("compliance:manage"); + expect(perms).toContain("webhooks:manage"); + expect(perms).toContain("security:manage"); + expect(perms.length).toBe(17); }); it("editor has collaborative but not admin permissions", async () => { diff --git a/tests/unit/api/webhook-delivery.test.ts b/tests/unit/api/webhook-delivery.test.ts new file mode 100644 index 00000000..54be4a3a --- /dev/null +++ b/tests/unit/api/webhook-delivery.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("webhook delivery", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("delivers a batch of events", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook("https://siem.example.com/input", "Bearer test-token", [ + { event: "LOGIN_SUCCESS", timestamp: "2026-01-01T00:00:00Z" }, + ]); + + expect(result.success).toBe(true); + expect(result.attempts).toBe(1); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("https://siem.example.com/input"); + expect(opts.headers["Authorization"]).toBe("Bearer test-token"); + const body = JSON.parse(opts.body); + expect(body.source).toBe("snapotter"); + expect(body.version).toBe("1"); + expect(body.events).toHaveLength(1); + }); + + it("retries on server error", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 502 }) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], { + maxRetries: 3, + initialDelayMs: 1, + }); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not retry on 4xx client errors", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], { + maxRetries: 3, + initialDelayMs: 1, + }); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("retries on network errors", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("connection refused")) + .mockRejectedValueOnce(new Error("connection refused")) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], { + maxRetries: 3, + initialDelayMs: 1, + }); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("fails after exhausting retries", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("down")); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], { + maxRetries: 2, + initialDelayMs: 1, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("down"); + expect(result.attempts).toBe(3); // initial + 2 retries + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("omits Authorization header when auth is empty", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + await deliverWebhook("https://example.com", "", [{ event: "test" }]); + + const headers = fetchMock.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBeUndefined(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 78827e10..e19c9286 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -122,6 +122,7 @@ export default defineConfig({ sharp: path.join(apiNodeModules, "sharp"), ioredis: path.join(apiNodeModules, "ioredis"), bullmq: path.join(apiNodeModules, "bullmq"), + otpauth: path.join(apiNodeModules, "otpauth"), "openid-client": path.join(apiNodeModules, "openid-client"), "opentype.js": path.join(apiNodeModules, "opentype.js"), "posthog-node": path.join(apiNodeModules, "posthog-node"),