From cafd2d8b6547eccdb9735acf344207534c13aeae Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:17:17 +0800 Subject: [PATCH 01/49] feat(db): add audit integrity/requestId columns, user_preferences table, audit indexes --- apps/api/drizzle/0002_fair_sprite.sql | 14 + apps/api/drizzle/meta/0002_snapshot.json | 965 +++++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 9 +- apps/api/src/db/schema.ts | 53 +- 4 files changed, 1027 insertions(+), 14 deletions(-) create mode 100644 apps/api/drizzle/0002_fair_sprite.sql create mode 100644 apps/api/drizzle/meta/0002_snapshot.json 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/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..49f0db69 --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,965 @@ +{ + "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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 9ba13992..a0d63cd6 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1781189798348, "tag": "0001_jobs_spine", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1781338621449, + "tag": "0002_fair_sprite", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index f3ac03e2..d14cedd7 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"; @@ -124,19 +125,29 @@ 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(), @@ -169,3 +180,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] })], +); From 86d6f50ea6696032bed8f24ba0fd58fe54af5887 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:20:24 +0800 Subject: [PATCH 02/49] feat: add enterprise Phase 1-4 feature flags and new permissions --- apps/api/src/permissions.ts | 3 ++ apps/api/src/plugins/auth.ts | 3 ++ packages/enterprise/src/index.ts | 3 +- packages/enterprise/src/license.ts | 21 +++++++++++++- packages/shared/src/permissions.ts | 5 +++- tests/unit/api/enterprise-flags.test.ts | 38 +++++++++++++++++++++++++ 6 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 tests/unit/api/enterprise-flags.test.ts diff --git a/apps/api/src/permissions.ts b/apps/api/src/permissions.ts index 84320856..b67c4803 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", diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 38022e47..459a6e38 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -211,6 +211,9 @@ export async function ensureBuiltinRoles(): Promise { "features:manage", "system:health", "audit:read", + "compliance:manage", + "webhooks:manage", + "security:manage", ], isBuiltin: true, }, diff --git a/packages/enterprise/src/index.ts b/packages/enterprise/src/index.ts index c374d924..23f91e8a 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, PLAN_FEATURES, type EnterpriseFeature, type LicensePayload }; 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/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/tests/unit/api/enterprise-flags.test.ts b/tests/unit/api/enterprise-flags.test.ts new file mode 100644 index 00000000..0ea870c6 --- /dev/null +++ b/tests/unit/api/enterprise-flags.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { ENTERPRISE_FEATURES, PLAN_FEATURES } from "@snapotter/enterprise"; + +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); + }); +}); From f6d54793346b11c30c8daeba3af63a2bddebb641 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:22:01 +0800 Subject: [PATCH 03/49] test: add enterprise feature mock utilities --- tests/helpers/enterprise-mock.ts | 34 +++++++++++++++++ tests/unit/api/enterprise-mock.test.ts | 51 ++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/helpers/enterprise-mock.ts create mode 100644 tests/unit/api/enterprise-mock.test.ts diff --git a/tests/helpers/enterprise-mock.ts b/tests/helpers/enterprise-mock.ts new file mode 100644 index 00000000..6fe3aab0 --- /dev/null +++ b/tests/helpers/enterprise-mock.ts @@ -0,0 +1,34 @@ +import { vi } from "vitest"; +import type { EnterpriseFeature } from "@snapotter/enterprise"; + +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/unit/api/enterprise-mock.test.ts b/tests/unit/api/enterprise-mock.test.ts new file mode 100644 index 00000000..50e67c43 --- /dev/null +++ b/tests/unit/api/enterprise-mock.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } 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(); + }); +}); From 5d2f520d7823969578945acfd3e6799bfd6ff3f7 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:25:58 +0800 Subject: [PATCH 04/49] feat(audit): capture IP address, make TRUST_PROXY configurable --- apps/api/src/index.ts | 10 +++++++++- apps/api/src/lib/audit.ts | 5 +++-- apps/api/src/lib/env.ts | 5 +---- apps/api/src/plugins/auth.ts | 18 +++++++++--------- apps/api/src/plugins/oidc.ts | 14 +++++++------- apps/api/src/routes/api-keys.ts | 4 ++-- apps/api/src/routes/roles.ts | 6 +++--- apps/api/src/routes/settings.ts | 2 +- apps/api/src/routes/user-files.ts | 6 +++--- 9 files changed, 38 insertions(+), 32 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2a884166..ce912121 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -172,6 +172,14 @@ 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({ logger: { level: env.LOG_LEVEL, @@ -194,7 +202,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 }, }); diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index f0b492f4..9c0bbd30 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -39,8 +39,9 @@ export async function auditLog( logger: FastifyBaseLogger, event: AuditEvent, details: Record = {}, + ip: string | null = null, ): Promise { - logger.info({ audit: true, event, ...details }, `[AUDIT] ${event}`); + logger.info({ audit: true, event, ip, ...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"; @@ -56,7 +57,7 @@ export async function auditLog( targetType, targetId, details, - ipAddress: null, + ipAddress: ip, }); } catch { logger.warn({ event }, "Failed to write audit log to DB"); diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index afbdd261..637a71fd 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") diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 459a6e38..94b3f857 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -294,7 +294,7 @@ export async function authRoutes(app: FastifyInstance): Promise { await auditLog(request.log, "LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "unknown_user", - }); + }, request.ip); return reply.status(401).send({ error: "Invalid credentials" }); } @@ -303,7 +303,7 @@ export async function authRoutes(app: FastifyInstance): Promise { await auditLog(request.log, "LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "bad_password", - }); + }, request.ip); return reply.status(401).send({ error: "Invalid credentials" }); } @@ -317,7 +317,7 @@ export async function authRoutes(app: FastifyInstance): Promise { expiresAt, }); - await auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username }); + await auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username }, request.ip); const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, user.team)); @@ -378,7 +378,7 @@ export async function authRoutes(app: FastifyInstance): Promise { cookieReply.clearCookie("snapotter-session", { path: "/" }); } - await auditLog(request.log, "LOGOUT", { userId: user?.id }); + await auditLog(request.log, "LOGOUT", { userId: user?.id }, request.ip); return reply.send({ ok: true, ...(logoutUrl && { logoutUrl }) }); }); @@ -503,7 +503,7 @@ export async function authRoutes(app: FastifyInstance): Promise { await auditLog(request.log, "PASSWORD_CHANGED", { userId: authUser.id, username: authUser.username, - }); + }, request.ip); return reply.send({ ok: true }); }); @@ -674,7 +674,7 @@ export async function authRoutes(app: FastifyInstance): Promise { newUserId: id, newUsername: body.username, role, - }); + }, request.ip); return reply.status(201).send({ id, @@ -788,7 +788,7 @@ export async function authRoutes(app: FastifyInstance): Promise { adminId: admin.id, targetUserId: id, changes: { role: updates.role, team: updates.team }, - }); + }, request.ip); return reply.send({ ok: true }); }, @@ -849,7 +849,7 @@ export async function authRoutes(app: FastifyInstance): Promise { adminId: admin.id, targetUserId: id, targetUsername: user.username, - }); + }, request.ip); return reply.send({ ok: true }); }, @@ -887,7 +887,7 @@ export async function authRoutes(app: FastifyInstance): Promise { adminId: admin.id, deletedUserId: id, deletedUsername: user.username, - }); + }, request.ip); return reply.send({ ok: true }); }, diff --git a/apps/api/src/plugins/oidc.ts b/apps/api/src/plugins/oidc.ts index 464c925e..644c8ea0 100644 --- a/apps/api/src/plugins/oidc.ts +++ b/apps/api/src/plugins/oidc.ts @@ -229,7 +229,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { ); await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: sanitizeAuditInput(String(query.error)), - }); + }, request.ip); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -257,7 +257,7 @@ 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" }); + await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" }, request.ip); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -265,7 +265,7 @@ 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" }); + await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "no_id_token" }, request.ip); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -318,7 +318,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { userId: existingByEmail.id, username: existingByEmail.username, email, - }); + }, request.ip); } } @@ -361,7 +361,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { username: uniqueUsername, email, role: env.OIDC_DEFAULT_ROLE, - }); + }, request.ip); } // 4d. No user found and no auto-create @@ -370,7 +370,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "user_not_authorized", sub: sanitizeAuditInput(String(sub)), - }); + }, request.ip); return redirectToLogin(reply, "oidc_user_not_authorized"); } @@ -391,7 +391,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { await auditLog(request.log, "OIDC_LOGIN_SUCCESS", { userId, username: user?.username ?? username, - }); + }, request.ip); // 6. Set session cookie reply.setCookie("snapotter-session", token, { diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts index 87016e0e..0b04ba24 100644 --- a/apps/api/src/routes/api-keys.ts +++ b/apps/api/src/routes/api-keys.ts @@ -86,7 +86,7 @@ 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 auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name }, request.ip); // Return the raw key ONCE — it cannot be retrieved again return reply.status(201).send({ @@ -155,7 +155,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 auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id }, request.ip); return reply.send({ ok: true }); }, diff --git a/apps/api/src/routes/roles.ts b/apps/api/src/routes/roles.ts index 96e548e9..2a97010b 100644 --- a/apps/api/src/routes/roles.ts +++ b/apps/api/src/routes/roles.ts @@ -116,7 +116,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { createdBy: user.id, }); - await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }); + await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }, request.ip); return reply.status(201).send({ id, @@ -185,7 +185,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 auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id }, request.ip); return reply.send({ ok: true }); }, @@ -220,7 +220,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { adminId: user.id, roleId: id, roleName: role.name, - }); + }, request.ip); return reply.send({ ok: true }); }, diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 2b29dcc6..096c6150 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -95,7 +95,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { adminId: admin.id, username: admin.username, keys: entries.map((e) => e.key), - }); + }, request.ip); } return reply.send({ ok: true, updatedCount: entries.length }); diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 7497b174..3c171918 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -263,7 +263,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { userId, count: created.length, files: created.map((f) => f.originalName), - }); + }, request.ip); return reply.status(201).send({ files: created }); }, @@ -495,7 +495,7 @@ 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 auditLog(request.log, "FILE_DELETED", { userId: user.id, count: 0, ids }, request.ip); return reply.send({ deleted: 0 }); } @@ -546,7 +546,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { userId: user.id, count: chainRows.length, ids, - }); + }, request.ip); return reply.send({ deleted: chainRows.length }); }); From 37b2b9c2eef0e2926dbee03f6b24f16eb53f544e Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:27:53 +0800 Subject: [PATCH 05/49] feat(audit): extensible event type system with shared constants --- apps/api/src/lib/audit.ts | 37 +++++++++-------------------- packages/shared/src/audit-events.ts | 37 +++++++++++++++++++++++++++++ packages/shared/src/index.ts | 1 + 3 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 packages/shared/src/audit-events.ts diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index 9c0bbd30..8e1bdf39 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -8,36 +8,14 @@ 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, ): Promise { @@ -64,18 +42,25 @@ export async function auditLog( } } -function deriveTargetType(event: AuditEvent): string | null { +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/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/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"; From 36f083ba64bfa2063c724223c3d59ffadb626cb5 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:31:49 +0800 Subject: [PATCH 06/49] feat(audit): add TOOL_EXECUTED logging with opt-in setting Add isToolAuditEnabled() helper that checks the auditToolOperations DB setting (off by default) or falls back to the enterprise audit_export feature flag. The createToolRoute factory now emits a TOOL_EXECUTED audit entry on successful tool execution when enabled, using a fire-and-forget pattern so a failed audit write never blocks the tool response. --- apps/api/src/lib/audit.ts | 30 +++++ apps/api/src/routes/tool-factory.ts | 25 ++++ .../integration/audit-tool-operations.test.ts | 111 ++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 tests/integration/audit-tool-operations.test.ts diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index 8e1bdf39..ec2c9e16 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -1,9 +1,39 @@ import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; import type { FastifyBaseLogger } from "fastify"; import { db, schema } from "../db/index.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)"; } diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 7df69020..3e0a9981 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -469,6 +469,31 @@ 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, auditLog }) => + isToolAuditEnabled().then((enabled) => { + if (!enabled) return; + const user = getAuthUser(request); + return auditLog( + request.log, + "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, + }, + request.ip, + ); + }), + ) + .catch(() => {}); + return reply.send({ jobId, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, diff --git a/tests/integration/audit-tool-operations.test.ts b/tests/integration/audit-tool-operations.test.ts new file mode 100644 index 00000000..2984666e --- /dev/null +++ b/tests/integration/audit-tool-operations.test.ts @@ -0,0 +1,111 @@ +/** + * 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); + }); +}); From 2520cdd556c8a5946b4eb6426aebc8811944e8d9 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:35:28 +0800 Subject: [PATCH 07/49] feat: add AES-256-GCM encryption at rest for sensitive settings --- apps/api/src/lib/encryption.ts | 69 +++++++++++++++++++++++++++++++ apps/api/src/lib/env.ts | 2 + apps/api/src/routes/settings.ts | 34 ++++++++++++--- tests/unit/api/encryption.test.ts | 58 ++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/lib/encryption.ts create mode 100644 tests/unit/api/encryption.test.ts diff --git a/apps/api/src/lib/encryption.ts b/apps/api/src/lib/encryption.ts new file mode 100644 index 00000000..517f52b1 --- /dev/null +++ b/apps/api/src/lib/encryption.ts @@ -0,0 +1,69 @@ +import { + createCipheriv, + createDecipheriv, + randomBytes, + hkdf as hkdfCb, +} 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 637a71fd..a995c34d 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -97,6 +97,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") { diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 096c6150..5a73caf2 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -11,6 +11,8 @@ 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 { env } from "../config.js"; +import { encrypt, decrypt, isEncrypted } from "../lib/encryption.js"; import { requirePermission } from "../permissions.js"; import { requireAuth } from "../plugins/auth.js"; @@ -18,7 +20,27 @@ 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 +54,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 +96,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,10 +107,10 @@ 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 }); } } @@ -125,7 +149,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/tests/unit/api/encryption.test.ts b/tests/unit/api/encryption.test.ts new file mode 100644 index 00000000..005472ff --- /dev/null +++ b/tests/unit/api/encryption.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { + encrypt, + decrypt, + isEncrypted, + deriveAuditHmacKey, +} 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); + }); +}); From 3016571c2b2925e8fc7677d9d2daae2bd8191db1 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:39:22 +0800 Subject: [PATCH 08/49] feat(web): add data retention settings to admin UI --- .../components/settings/settings-dialog.tsx | 46 +++++++++++++++++++ packages/shared/src/i18n/ar.ts | 10 ++++ packages/shared/src/i18n/de.ts | 10 ++++ packages/shared/src/i18n/en.ts | 10 ++++ packages/shared/src/i18n/es.ts | 10 ++++ packages/shared/src/i18n/fr.ts | 10 ++++ packages/shared/src/i18n/hi.ts | 10 ++++ packages/shared/src/i18n/id.ts | 10 ++++ packages/shared/src/i18n/it.ts | 10 ++++ packages/shared/src/i18n/ja.ts | 10 ++++ packages/shared/src/i18n/ko.ts | 10 ++++ packages/shared/src/i18n/nl.ts | 10 ++++ packages/shared/src/i18n/pl.ts | 10 ++++ packages/shared/src/i18n/pt-BR.ts | 10 ++++ packages/shared/src/i18n/ru.ts | 10 ++++ packages/shared/src/i18n/sv.ts | 10 ++++ packages/shared/src/i18n/th.ts | 10 ++++ packages/shared/src/i18n/tr.ts | 10 ++++ packages/shared/src/i18n/uk.ts | 10 ++++ packages/shared/src/i18n/vi.ts | 10 ++++ packages/shared/src/i18n/zh-CN.ts | 10 ++++ packages/shared/src/i18n/zh-TW.ts | 10 ++++ 22 files changed, 256 insertions(+) diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index b910ceca..1697233a 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -686,6 +686,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} + /> + +
{entry.actorUsername} + {entry.ipAddress && ( + + {entry.ipAddress} + + )} {entry.targetType && ( {entry.targetType} @@ -2720,6 +2738,9 @@ function AuditLogSection() { {t.settings.auditLog.tableHeaderUser} + + IP + {t.settings.auditLog.tableHeaderAction} @@ -2739,6 +2760,9 @@ function AuditLogSection() { {formatRelativeTime(entry.createdAt)} {entry.actorUsername} + + {entry.ipAddress ?? "---"} + {entry.action} @@ -2752,7 +2776,7 @@ function AuditLogSection() { {expandedId === entry.id && entry.details && ( - +
                             {JSON.stringify(entry.details, null, 2)}
                           
From 913dd6bbe1518e7b709dd002329417789e103f49 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:45:11 +0800 Subject: [PATCH 10/49] feat(enterprise): add audit log export endpoint (CSV/JSON) --- apps/api/src/index.ts | 4 + .../api/src/routes/enterprise/audit-export.ts | 140 ++++++++++++++++++ apps/api/src/routes/enterprise/index.ts | 6 + tests/integration/audit-export.test.ts | 79 ++++++++++ tests/integration/test-server.ts | 4 + 5 files changed, 233 insertions(+) create mode 100644 apps/api/src/routes/enterprise/audit-export.ts create mode 100644 apps/api/src/routes/enterprise/index.ts create mode 100644 tests/integration/audit-export.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ce912121..abf350dc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -50,6 +50,7 @@ import { settingsRoutes } from "./routes/settings.js"; import { teamsRoutes } from "./routes/teams.js"; import { registerToolRoutes } from "./routes/tools/index.js"; import { userFileRoutes } from "./routes/user-files.js"; +import { registerEnterpriseRoutes } from "./routes/enterprise/index.js"; // Run before anything else try { @@ -347,6 +348,9 @@ 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); 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..e655a0a4 --- /dev/null +++ b/apps/api/src/routes/enterprise/audit-export.ts @@ -0,0 +1,140 @@ +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 entries = await db + .select() + .from(schema.auditLog) + .where(where) + .orderBy(desc(schema.auditLog.createdAt)); + + 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/index.ts b/apps/api/src/routes/enterprise/index.ts new file mode 100644 index 00000000..155c0b56 --- /dev/null +++ b/apps/api/src/routes/enterprise/index.ts @@ -0,0 +1,6 @@ +import type { FastifyInstance } from "fastify"; +import { registerAuditExport } from "./audit-export.js"; + +export async function registerEnterpriseRoutes(app: FastifyInstance) { + await registerAuditExport(app); +} diff --git a/tests/integration/audit-export.test.ts b/tests/integration/audit-export.test.ts new file mode 100644 index 00000000..600545f5 --- /dev/null +++ b/tests/integration/audit-export.test.ts @@ -0,0 +1,79 @@ +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"; +import { eq } from "drizzle-orm"; + +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/test-server.ts b/tests/integration/test-server.ts index 59922da1..38df3cf0 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -64,6 +64,7 @@ import { settingsRoutes } from "../../apps/api/src/routes/settings.js"; import { teamsRoutes } from "../../apps/api/src/routes/teams.js"; import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js"; import { userFileRoutes } from "../../apps/api/src/routes/user-files.js"; +import { registerEnterpriseRoutes } from "../../apps/api/src/routes/enterprise/index.js"; // Run migrations (idempotent -- template already has the schema, but this // ensures the __drizzle_migrations journal is consistent in each fork). @@ -185,6 +186,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); From 895e29e93fb141a279dcd090ad0199f248faed2f Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:48:09 +0800 Subject: [PATCH 11/49] feat(enterprise): add tamper-resistant audit mode with HMAC integrity --- apps/api/src/jobs/system-jobs.ts | 19 +++++++--- apps/api/src/lib/audit-integrity.ts | 26 ++++++++++++++ apps/api/src/lib/audit.ts | 38 +++++++++++++++++++- tests/unit/api/audit-integrity.test.ts | 48 ++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/lib/audit-integrity.ts create mode 100644 tests/unit/api/audit-integrity.test.ts diff --git a/apps/api/src/jobs/system-jobs.ts b/apps/api/src/jobs/system-jobs.ts index fa093197..511e4fde 100644 --- a/apps/api/src/jobs/system-jobs.ts +++ b/apps/api/src/jobs/system-jobs.ts @@ -10,7 +10,7 @@ * calling runSystemJob); anything else is a bug. */ import type { Job } from "bullmq"; -import { inArray, sql } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { getMaxAgeMs } from "../lib/cleanup.js"; @@ -158,8 +158,19 @@ async function retentionSweep(): Promise { ); } 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'`, + ); + } } } diff --git a/apps/api/src/lib/audit-integrity.ts b/apps/api/src/lib/audit-integrity.ts new file mode 100644 index 00000000..89367e7a --- /dev/null +++ b/apps/api/src/lib/audit-integrity.ts @@ -0,0 +1,26 @@ +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 ec2c9e16..a1a7e49f 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -1,7 +1,10 @@ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import type { FastifyBaseLogger } 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; @@ -56,9 +59,10 @@ export async function auditLog( 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, @@ -69,6 +73,38 @@ export async function auditLog( }); } 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, + }; + 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"); + } } } diff --git a/tests/unit/api/audit-integrity.test.ts b/tests/unit/api/audit-integrity.test.ts new file mode 100644 index 00000000..1d312bc3 --- /dev/null +++ b/tests/unit/api/audit-integrity.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } 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)); + }); +}); From ab88b9ad0d9104ae69862309f9d2b35c0251d6bc Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:49:47 +0800 Subject: [PATCH 12/49] feat: add webhook delivery module with retry and backoff --- apps/api/src/lib/webhook-delivery.ts | 61 +++++++++++++ tests/unit/api/webhook-delivery.test.ts | 112 ++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 apps/api/src/lib/webhook-delivery.ts create mode 100644 tests/unit/api/webhook-delivery.test.ts diff --git a/apps/api/src/lib/webhook-delivery.ts b/apps/api/src/lib/webhook-delivery.ts new file mode 100644 index 00000000..42005ab7 --- /dev/null +++ b/apps/api/src/lib/webhook-delivery.ts @@ -0,0 +1,61 @@ +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/tests/unit/api/webhook-delivery.test.ts b/tests/unit/api/webhook-delivery.test.ts new file mode 100644 index 00000000..6bb69fa3 --- /dev/null +++ b/tests/unit/api/webhook-delivery.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } 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(); + }); +}); From d3f30a2f5d1d846bb4d56a3d2f0c208065258ba8 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:53:32 +0800 Subject: [PATCH 13/49] feat(enterprise): add SIEM webhook forwarding with circuit breaker --- apps/api/src/jobs/siem-forward.ts | 120 ++++++++++++++++++ apps/api/src/jobs/system-jobs.ts | 5 + apps/api/src/routes/enterprise/index.ts | 2 + apps/api/src/routes/enterprise/siem.ts | 148 ++++++++++++++++++++++ tests/integration/siem-forwarding.test.ts | 48 +++++++ 5 files changed, 323 insertions(+) create mode 100644 apps/api/src/jobs/siem-forward.ts create mode 100644 apps/api/src/routes/enterprise/siem.ts create mode 100644 tests/integration/siem-forwarding.test.ts diff --git a/apps/api/src/jobs/siem-forward.ts b/apps/api/src/jobs/siem-forward.ts new file mode 100644 index 00000000..f03635f6 --- /dev/null +++ b/apps/api/src/jobs/siem-forward.ts @@ -0,0 +1,120 @@ +/** + * SIEM forwarding job. + * + * 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_id: cursor (last successfully forwarded audit log ID) + * - siem_consecutive_failures: circuit breaker counter + */ +import { asc, eq, gt } from "drizzle-orm"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { decrypt, isEncrypted } from "../lib/encryption.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_id"; +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; +} + +async function upsertSetting(key: string, value: string): Promise { + const [existing] = await db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, key)); + + if (existing) { + await db + .update(schema.settings) + .set({ value, updatedAt: new Date() }) + .where(eq(schema.settings.key, key)); + } else { + await db.insert(schema.settings).values({ key, value }); + } +} + +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 + const conditions = cursor ? gt(schema.auditLog.id, cursor) : 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 lastId = rows[rows.length - 1].id; + await upsertSetting(CURSOR_KEY, lastId); + 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/system-jobs.ts b/apps/api/src/jobs/system-jobs.ts index 511e4fde..a8c822c0 100644 --- a/apps/api/src/jobs/system-jobs.ts +++ b/apps/api/src/jobs/system-jobs.ts @@ -16,11 +16,13 @@ import { db, schema } from "../db/index.js"; import { getMaxAgeMs } from "../lib/cleanup.js"; import { deletePrefix, listJobDirs, type ObjectInfo } from "../lib/object-storage.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", } as const; // -- Scheduling --------------------------------------------------------------- @@ -39,6 +41,7 @@ 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 }); } /** Enqueue a one-shot system job (e.g. startup cleanup trigger). */ @@ -58,6 +61,8 @@ 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(); default: // batch-finalize runs on the system pool too but is routed by the // worker before calling runSystemJob. Anything else is a bug. diff --git a/apps/api/src/routes/enterprise/index.ts b/apps/api/src/routes/enterprise/index.ts index 155c0b56..c2c6f962 100644 --- a/apps/api/src/routes/enterprise/index.ts +++ b/apps/api/src/routes/enterprise/index.ts @@ -1,6 +1,8 @@ import type { FastifyInstance } from "fastify"; import { registerAuditExport } from "./audit-export.js"; +import { registerSiemRoutes } from "./siem.js"; export async function registerEnterpriseRoutes(app: FastifyInstance) { await registerAuditExport(app); + await registerSiemRoutes(app); } diff --git a/apps/api/src/routes/enterprise/siem.ts b/apps/api/src/routes/enterprise/siem.ts new file mode 100644 index 00000000..1896b7a8 --- /dev/null +++ b/apps/api/src/routes/enterprise/siem.ts @@ -0,0 +1,148 @@ +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 { 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 auditLog(request.log, "SETTINGS_UPDATED", { + adminId: user.id, + username: user.username, + keys: [SETTINGS_KEY], + }, request.ip); + + 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/tests/integration/siem-forwarding.test.ts b/tests/integration/siem-forwarding.test.ts new file mode 100644 index 00000000..8447158a --- /dev/null +++ b/tests/integration/siem-forwarding.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeAll, afterAll } 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); + }); +}); From c1dc27f2484120f7f4571c8472fccd292e43f259 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 16:57:20 +0800 Subject: [PATCH 14/49] feat(enterprise): add audit log archival with crash-safe state machine --- apps/api/src/jobs/audit-archive.ts | 223 ++++++++++++++++++++++++++ apps/api/src/jobs/system-jobs.ts | 8 + tests/unit/api/audit-archival.test.ts | 40 +++++ 3 files changed, 271 insertions(+) create mode 100644 apps/api/src/jobs/audit-archive.ts create mode 100644 tests/unit/api/audit-archival.test.ts diff --git a/apps/api/src/jobs/audit-archive.ts b/apps/api/src/jobs/audit-archive.ts new file mode 100644 index 00000000..7115c82f --- /dev/null +++ b/apps/api/src/jobs/audit-archive.ts @@ -0,0 +1,223 @@ +/** + * 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"; + +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 (same pattern as siem-forward.ts) ------------------------ + +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 upsertSetting(key: string, value: string): Promise { + const [existing] = await db.select().from(schema.settings).where(eq(schema.settings.key, key)); + + if (existing) { + await db + .update(schema.settings) + .set({ value, updatedAt: new Date() }) + .where(eq(schema.settings.key, key)); + } else { + await db.insert(schema.settings).values({ key, value }); + } +} + +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/system-jobs.ts b/apps/api/src/jobs/system-jobs.ts index a8c822c0..43f22bd9 100644 --- a/apps/api/src/jobs/system-jobs.ts +++ b/apps/api/src/jobs/system-jobs.ts @@ -15,6 +15,7 @@ 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"; @@ -23,6 +24,7 @@ export const SYSTEM_JOBS = { sessionPurge: "system:session-purge", retention: "system:retention", siemForward: "system:siem-forward", + auditArchive: "system:audit-archive", } as const; // -- Scheduling --------------------------------------------------------------- @@ -42,6 +44,10 @@ 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 * *", + }); } /** Enqueue a one-shot system job (e.g. startup cleanup trigger). */ @@ -63,6 +69,8 @@ export async function runSystemJob(job: Job): Promise { return retentionSweep(); case SYSTEM_JOBS.siemForward: return runSiemForward(); + case SYSTEM_JOBS.auditArchive: + return runAuditArchive(); default: // batch-finalize runs on the system pool too but is routed by the // worker before calling runSystemJob. Anything else is a bug. 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(); + }); +}); From 1cf1f47d6f0554853a501ae1220dbdc0678820cb Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 17:04:27 +0800 Subject: [PATCH 15/49] feat: add request correlation IDs to audit logs and response headers --- apps/api/src/index.ts | 2 ++ apps/api/src/lib/audit.ts | 16 ++++++++++-- apps/api/src/plugins/auth.ts | 36 ++++++++++++++------------ apps/api/src/plugins/oidc.ts | 28 ++++++++++---------- apps/api/src/routes/api-keys.ts | 6 ++--- apps/api/src/routes/enterprise/siem.ts | 6 ++--- apps/api/src/routes/roles.ts | 10 +++---- apps/api/src/routes/settings.ts | 6 ++--- apps/api/src/routes/tool-factory.ts | 27 ++++++++----------- apps/api/src/routes/user-files.ts | 12 ++++----- 10 files changed, 81 insertions(+), 68 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index abf350dc..67067bfb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -182,6 +182,7 @@ function parseTrustProxy(value: string): boolean | number | string { } const app = Fastify({ + genReqId: (req) => (req.headers["x-request-id"] as string) ?? randomUUID(), logger: { level: env.LOG_LEVEL, transport: { @@ -255,6 +256,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"); diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index a1a7e49f..b9eaafe0 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; -import type { FastifyBaseLogger } from "fastify"; +import type { FastifyBaseLogger, FastifyRequest } from "fastify"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { computeHmac } from "./audit-integrity.js"; @@ -51,8 +51,9 @@ export async function auditLog( event: string, details: Record = {}, ip: string | null = null, + requestId: string | null = null, ): Promise { - logger.info({ audit: true, event, ip, ...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"; @@ -70,6 +71,7 @@ export async function auditLog( targetId, details, ipAddress: ip, + requestId, }); } catch { logger.warn({ event }, "Failed to write audit log to DB"); @@ -95,6 +97,7 @@ export async function auditLog( targetId, details, ipAddress: ip, + requestId, }; const integrity = computeHmac(rowData, hmacKey); await db @@ -108,6 +111,15 @@ export async function auditLog( } } +/** + * 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_") || diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 94b3f857..4561312c 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -5,7 +5,7 @@ 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 { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; import { getPermissions, requirePermission } from "../permissions.js"; const scryptAsync = promisify(scrypt); @@ -290,20 +290,22 @@ 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", { + await audit("LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "unknown_user", - }, request.ip); + }); return reply.status(401).send({ error: "Invalid credentials" }); } const valid = await verifyPassword(body.password, user.passwordHash); if (!valid) { - await auditLog(request.log, "LOGIN_FAILED", { + await audit("LOGIN_FAILED", { username: sanitizeAuditInput(body.username), reason: "bad_password", - }, request.ip); + }); return reply.status(401).send({ error: "Invalid credentials" }); } @@ -317,7 +319,7 @@ export async function authRoutes(app: FastifyInstance): Promise { expiresAt, }); - await auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username }, request.ip); + 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)); @@ -378,7 +380,7 @@ export async function authRoutes(app: FastifyInstance): Promise { cookieReply.clearCookie("snapotter-session", { path: "/" }); } - await auditLog(request.log, "LOGOUT", { userId: user?.id }, request.ip); + await auditFromRequest(request)("LOGOUT", { userId: user?.id }); return reply.send({ ok: true, ...(logoutUrl && { logoutUrl }) }); }); @@ -500,10 +502,10 @@ 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, - }, request.ip); + }); return reply.send({ ok: true }); }); @@ -669,12 +671,12 @@ 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, role, - }, request.ip); + }); return reply.status(201).send({ id, @@ -784,11 +786,11 @@ 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 }, - }, request.ip); + }); return reply.send({ ok: true }); }, @@ -845,11 +847,11 @@ 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, - }, request.ip); + }); return reply.send({ ok: true }); }, @@ -883,11 +885,11 @@ 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, - }, request.ip); + }); return reply.send({ ok: true }); }, diff --git a/apps/api/src/plugins/oidc.ts b/apps/api/src/plugins/oidc.ts index 644c8ea0..0ef5d2fc 100644 --- a/apps/api/src/plugins/oidc.ts +++ b/apps/api/src/plugins/oidc.ts @@ -5,7 +5,7 @@ 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 { createSessionToken } from "./auth.js"; // ── Types ───────────────────────────────────────────────────────── @@ -221,15 +221,17 @@ 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", { + await audit("OIDC_LOGIN_FAILED", { reason: sanitizeAuditInput(String(query.error)), - }, request.ip); + }); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -257,7 +259,7 @@ 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" }, request.ip); + await audit("OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" }); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -265,7 +267,7 @@ 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" }, request.ip); + await audit("OIDC_LOGIN_FAILED", { reason: "no_id_token" }); return redirectToLogin(reply, "oidc_auth_failed"); } @@ -314,11 +316,11 @@ export async function oidcRoutes(app: FastifyInstance): Promise { }) .where(eq(schema.users.id, existingByEmail.id)); userId = existingByEmail.id; - await auditLog(request.log, "OIDC_USER_LINKED", { + await audit("OIDC_USER_LINKED", { userId: existingByEmail.id, username: existingByEmail.username, email, - }, request.ip); + }); } } @@ -356,21 +358,21 @@ export async function oidcRoutes(app: FastifyInstance): Promise { }); userId = newUserId; - await auditLog(request.log, "OIDC_USER_CREATED", { + await audit("OIDC_USER_CREATED", { userId: newUserId, username: uniqueUsername, email, role: env.OIDC_DEFAULT_ROLE, - }, request.ip); + }); } // 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", { + await audit("OIDC_LOGIN_FAILED", { reason: "user_not_authorized", sub: sanitizeAuditInput(String(sub)), - }, request.ip); + }); return redirectToLogin(reply, "oidc_user_not_authorized"); } @@ -388,10 +390,10 @@ export async function oidcRoutes(app: FastifyInstance): Promise { // 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", { + await audit("OIDC_LOGIN_SUCCESS", { userId, username: user?.username ?? username, - }, request.ip); + }); // 6. Set session cookie reply.setCookie("snapotter-session", token, { diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts index 0b04ba24..647e0503 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,7 @@ 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 }, request.ip); + 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 +155,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 }, request.ip); + await auditFromRequest(request)("API_KEY_DELETED", { userId: user.id, keyId: id }); return reply.send({ ok: true }); }, diff --git a/apps/api/src/routes/enterprise/siem.ts b/apps/api/src/routes/enterprise/siem.ts index 1896b7a8..772e3611 100644 --- a/apps/api/src/routes/enterprise/siem.ts +++ b/apps/api/src/routes/enterprise/siem.ts @@ -3,7 +3,7 @@ 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 { encrypt, isEncrypted } from "../../lib/encryption.js"; import { requirePermission } from "../../permissions.js"; @@ -115,11 +115,11 @@ export async function registerSiemRoutes(app: FastifyInstance): Promise { await db.insert(schema.settings).values({ key: SETTINGS_KEY, value }); } - await auditLog(request.log, "SETTINGS_UPDATED", { + await auditFromRequest(request)("SETTINGS_UPDATED", { adminId: user.id, username: user.username, keys: [SETTINGS_KEY], - }, request.ip); + }); return reply.send({ ok: true }); }, diff --git a/apps/api/src/routes/roles.ts b/apps/api/src/routes/roles.ts index 2a97010b..d8a1258a 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[] = [ @@ -116,7 +116,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { createdBy: user.id, }); - await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }, request.ip); + await auditFromRequest(request)("ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }); return reply.status(201).send({ id, @@ -185,7 +185,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 }, request.ip); + await auditFromRequest(request)("ROLE_UPDATED", { adminId: user.id, roleId: id }); return reply.send({ ok: true }); }, @@ -216,11 +216,11 @@ 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, - }, request.ip); + }); return reply.send({ ok: true }); }, diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 5a73caf2..930dfd18 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -10,7 +10,7 @@ import { 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 { env } from "../config.js"; import { encrypt, decrypt, isEncrypted } from "../lib/encryption.js"; import { requirePermission } from "../permissions.js"; @@ -115,11 +115,11 @@ export async function settingsRoutes(app: FastifyInstance): Promise { } 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), - }, request.ip); + }); } return reply.send({ ok: true, updatedCount: entries.length }); diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 3e0a9981..17fc6f9f 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -471,25 +471,20 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig // Fire-and-forget: audit log must never block the response import("../lib/audit.js") - .then(({ isToolAuditEnabled, auditLog }) => + .then(({ isToolAuditEnabled, auditFromRequest }) => isToolAuditEnabled().then((enabled) => { if (!enabled) return; const user = getAuthUser(request); - return auditLog( - request.log, - "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, - }, - request.ip, - ); + 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(() => {}); diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 3c171918..9aad81cb 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, @@ -259,11 +259,11 @@ 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), - }, request.ip); + }); return reply.status(201).send({ files: created }); }, @@ -495,7 +495,7 @@ 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 }, request.ip); + await auditFromRequest(request)("FILE_DELETED", { userId: user.id, count: 0, ids }); return reply.send({ deleted: 0 }); } @@ -542,11 +542,11 @@ export async function userFileRoutes(app: FastifyInstance): Promise { await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds)); } - await auditLog(request.log, "FILE_DELETED", { + await auditFromRequest(request)("FILE_DELETED", { userId: user.id, count: chainRows.length, ids, - }, request.ip); + }); return reply.send({ deleted: chainRows.length }); }); From de7bbcc18d328c208d2f17cb168a8687b6952eb3 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 17:07:39 +0800 Subject: [PATCH 16/49] test: update permission count tests for new enterprise permissions --- tests/unit/api/effective-permissions.test.ts | 4 ++-- tests/unit/api/permissions.test.ts | 7 +++++-- tests/unit/api/rbac-enforcement.test.ts | 7 +++++-- 3 files changed, 12 insertions(+), 6 deletions(-) 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/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 () => { From 3b7f44e50e450bff62560b9f21cb12330b0b2b82 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 17:07:49 +0800 Subject: [PATCH 17/49] fix: sort enterprise exports for Biome lint compliance --- packages/enterprise/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/enterprise/src/index.ts b/packages/enterprise/src/index.ts index 23f91e8a..188ede53 100644 --- a/packages/enterprise/src/index.ts +++ b/packages/enterprise/src/index.ts @@ -32,4 +32,4 @@ export type S3StorageModule = typeof import("./storage-s3.js"); export async function loadS3Storage(): Promise { return import("./storage-s3.js"); } -export { ENTERPRISE_FEATURES, PLAN_FEATURES, type EnterpriseFeature, type LicensePayload }; +export { ENTERPRISE_FEATURES, type EnterpriseFeature, type LicensePayload, PLAN_FEATURES }; From 7e5843cd403b2d59d844d5a1d8d3a9ccb0618899 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 20:39:38 +0800 Subject: [PATCH 18/49] feat(db): add data lifecycle columns (deleteAfter, legalHold, quotas, retention) --- apps/api/drizzle/0003_flimsy_dust.sql | 7 + apps/api/drizzle/meta/0003_snapshot.json | 1010 ++++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 7 + 4 files changed, 1031 insertions(+) create mode 100644 apps/api/drizzle/0003_flimsy_dust.sql create mode 100644 apps/api/drizzle/meta/0003_snapshot.json 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/meta/0003_snapshot.json b/apps/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..6bbd0e86 --- /dev/null +++ b/apps/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1010 @@ +{ + "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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index a0d63cd6..159ea0bb 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1781338621449, "tag": "0002_fair_sprite", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1781354362106, + "tag": "0003_flimsy_dust", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index d14cedd7..a791d12d 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -29,6 +29,9 @@ 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()), @@ -43,6 +46,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()), @@ -91,6 +97,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), From aaa8a37c9b3b490a8455311be89a0bd4b858a897 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 20:43:52 +0800 Subject: [PATCH 19/49] feat: add storage usage tracking with DB counters and reconciliation Increment users.storageUsed on file upload/save, decrement on delete (per-user via GREATEST to prevent negatives). Add per-team storage breakdown to GET /api/v1/admin/usage. Weekly reconciliation job (3 AM Sunday) recomputes counters from actual userFiles sums. --- apps/api/src/jobs/storage-reconciliation.ts | 53 +++++++++++++++++++++ apps/api/src/jobs/system-jobs.ts | 9 ++++ apps/api/src/routes/admin-ops.ts | 19 +++++++- apps/api/src/routes/user-files.ts | 48 ++++++++++++++++--- 4 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/jobs/storage-reconciliation.ts 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 43f22bd9..332e9099 100644 --- a/apps/api/src/jobs/system-jobs.ts +++ b/apps/api/src/jobs/system-jobs.ts @@ -25,6 +25,7 @@ export const SYSTEM_JOBS = { retention: "system:retention", siemForward: "system:siem-forward", auditArchive: "system:audit-archive", + storageReconciliation: "system:storage-reconciliation", } as const; // -- Scheduling --------------------------------------------------------------- @@ -48,6 +49,10 @@ export async function scheduleSystemJobs(): Promise { 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", + }); } /** Enqueue a one-shot system job (e.g. startup cleanup trigger). */ @@ -71,6 +76,10 @@ export async function runSystemJob(job: Job): Promise { return runSiemForward(); case SYSTEM_JOBS.auditArchive: return runAuditArchive(); + case SYSTEM_JOBS.storageReconciliation: { + const { storageReconciliationJob } = await import("./storage-reconciliation.js"); + return storageReconciliationJob(); + } default: // batch-finalize runs on the system pool too but is routed by the // worker before calling runSystemJob. Anything else is a bug. diff --git a/apps/api/src/routes/admin-ops.ts b/apps/api/src/routes/admin-ops.ts index b209c904..250a13fe 100644 --- a/apps/api/src/routes/admin-ops.ts +++ b/apps/api/src/routes/admin-ops.ts @@ -11,7 +11,7 @@ import { 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 +129,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 +172,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 +185,7 @@ export async function adminOpsRoutes(app: FastifyInstance): Promise { perUser, durations, storage, + teamStorage, }; }); } diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 9aad81cb..ceca1b8b 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -232,6 +232,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 +240,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 +251,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)); @@ -502,6 +511,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise { 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 +529,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,6 +553,22 @@ export async function userFileRoutes(app: FastifyInstance): Promise { await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds)); } + // 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, @@ -640,6 +667,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { // Create DB record const id = randomUUID(); + const fileSize = safeResultBuffer.length; try { await db.insert(schema.userFiles).values({ id, @@ -647,7 +675,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 +686,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 }); From d0642865590a56769adbc2d7bc6741a12800af52 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 20:49:28 +0800 Subject: [PATCH 20/49] feat: add team-level storage quotas with enforcement on upload and save --- apps/api/src/routes/teams.ts | 34 +++++++++++++---- apps/api/src/routes/user-files.ts | 61 +++++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 20 deletions(-) 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/user-files.ts b/apps/api/src/routes/user-files.ts index ceca1b8b..4cbf43a4 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -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 ───────────────────────────────────────────── From b60f550b3fa54c4b10681e6281e989678b6c955a Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 20:55:17 +0800 Subject: [PATCH 21/49] feat(enterprise): add per-team retention overrides with deleteAfter Compute a deleteAfter timestamp on job creation when the enterprise team_retention_overrides feature is enabled. The cleanup sweep now deletes storage for jobs past their deleteAfter deadline, running independently of the global TTL setting. --- apps/api/src/jobs/enqueue.ts | 46 ++++++++++++++++++++++++++++++++ apps/api/src/jobs/system-jobs.ts | 33 ++++++++++++++++++++--- 2 files changed, 76 insertions(+), 3 deletions(-) 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/system-jobs.ts b/apps/api/src/jobs/system-jobs.ts index 332e9099..8c759722 100644 --- a/apps/api/src/jobs/system-jobs.ts +++ b/apps/api/src/jobs/system-jobs.ts @@ -10,7 +10,7 @@ * calling runSystemJob); anything else is a bug. */ import type { Job } from "bullmq"; -import { eq, 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"; @@ -121,8 +121,35 @@ export function decideExpiry( } async function storageTtlSweep(): Promise<{ removed: number; failed: number }> { + // --- 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 }) + .from(schema.jobs) + .where(and(isNotNull(schema.jobs.deleteAfter), lt(schema.jobs.deleteAfter, new Date()))); + + for (const job of expiredJobs) { + 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"); @@ -168,7 +195,7 @@ 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 ---------------------------------------------------------- From fa7da7ce0c3bf480a59b429c1ad39ab23fe7cf18 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 21:01:04 +0800 Subject: [PATCH 22/49] feat(enterprise): add legal hold with cleanup bypass --- apps/api/src/jobs/system-jobs.ts | 62 +++++++++- apps/api/src/routes/enterprise/index.ts | 2 + apps/api/src/routes/enterprise/legal-hold.ts | 112 +++++++++++++++++++ tests/integration/legal-hold.test.ts | 91 +++++++++++++++ 4 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/routes/enterprise/legal-hold.ts create mode 100644 tests/integration/legal-hold.test.ts diff --git a/apps/api/src/jobs/system-jobs.ts b/apps/api/src/jobs/system-jobs.ts index 8c759722..4f1bceb0 100644 --- a/apps/api/src/jobs/system-jobs.ts +++ b/apps/api/src/jobs/system-jobs.ts @@ -121,16 +121,42 @@ 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({ name: schema.teams.name }) + .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.name), + ), + ); + 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 }) + .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}`); @@ -176,10 +202,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++; @@ -201,9 +245,19 @@ async function storageTtlSweep(): Promise<{ removed: number; failed: number }> { // -- 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.name + 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) { @@ -218,7 +272,9 @@ async function retentionSweep(): Promise { // 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'`, + 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/routes/enterprise/index.ts b/apps/api/src/routes/enterprise/index.ts index c2c6f962..6cb571e6 100644 --- a/apps/api/src/routes/enterprise/index.ts +++ b/apps/api/src/routes/enterprise/index.ts @@ -1,8 +1,10 @@ import type { FastifyInstance } from "fastify"; import { registerAuditExport } from "./audit-export.js"; +import { registerLegalHoldRoutes } from "./legal-hold.js"; import { registerSiemRoutes } from "./siem.js"; export async function registerEnterpriseRoutes(app: FastifyInstance) { await registerAuditExport(app); + await registerLegalHoldRoutes(app); await registerSiemRoutes(app); } 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/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); + }); +}); From b6a82268377bc331e38e65dcf80fe60aec6a2ff2 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 21:07:28 +0800 Subject: [PATCH 23/49] feat(enterprise): add GDPR user data export (async) --- apps/api/src/jobs/gdpr-export.ts | 105 +++++++++++++++++++ apps/api/src/jobs/system-jobs.ts | 16 +++ apps/api/src/routes/enterprise/gdpr.ts | 125 +++++++++++++++++++++++ apps/api/src/routes/enterprise/index.ts | 2 + tests/integration/gdpr-lifecycle.test.ts | 89 ++++++++++++++++ 5 files changed, 337 insertions(+) create mode 100644 apps/api/src/jobs/gdpr-export.ts create mode 100644 apps/api/src/routes/enterprise/gdpr.ts create mode 100644 tests/integration/gdpr-lifecycle.test.ts 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/system-jobs.ts b/apps/api/src/jobs/system-jobs.ts index 4f1bceb0..d1ca4f21 100644 --- a/apps/api/src/jobs/system-jobs.ts +++ b/apps/api/src/jobs/system-jobs.ts @@ -26,6 +26,7 @@ export const SYSTEM_JOBS = { siemForward: "system:siem-forward", auditArchive: "system:audit-archive", storageReconciliation: "system:storage-reconciliation", + gdprExport: "system:gdpr-export", } as const; // -- Scheduling --------------------------------------------------------------- @@ -80,6 +81,21 @@ export async function runSystemJob(job: Job): Promise { 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 }; + } default: // batch-finalize runs on the system pool too but is routed by the // worker before calling runSystemJob. Anything else is a bug. diff --git a/apps/api/src/routes/enterprise/gdpr.ts b/apps/api/src/routes/enterprise/gdpr.ts new file mode 100644 index 00000000..71147567 --- /dev/null +++ b/apps/api/src/routes/enterprise/gdpr.ts @@ -0,0 +1,125 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { db, schema } from "../../db/index.js"; +import { getQueue } from "../../jobs/queues.js"; +import { SYSTEM_JOBS } from "../../jobs/system-jobs.js"; +import { auditFromRequest } from "../../lib/audit.js"; +import { requirePermission } from "../../permissions.js"; + +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, + }); + }, + ); + + 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 index 6cb571e6..ce377831 100644 --- a/apps/api/src/routes/enterprise/index.ts +++ b/apps/api/src/routes/enterprise/index.ts @@ -1,10 +1,12 @@ import type { FastifyInstance } from "fastify"; import { registerAuditExport } from "./audit-export.js"; +import { registerGdprRoutes } from "./gdpr.js"; import { registerLegalHoldRoutes } from "./legal-hold.js"; import { registerSiemRoutes } from "./siem.js"; export async function registerEnterpriseRoutes(app: FastifyInstance) { await registerAuditExport(app); + await registerGdprRoutes(app); await registerLegalHoldRoutes(app); await registerSiemRoutes(app); } diff --git a/tests/integration/gdpr-lifecycle.test.ts b/tests/integration/gdpr-lifecycle.test.ts new file mode 100644 index 00000000..dfe9dd6f --- /dev/null +++ b/tests/integration/gdpr-lifecycle.test.ts @@ -0,0 +1,89 @@ +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 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); + }); +}); From dd2a50799a255a05e732de5943a7481b91b2c86d Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 21:13:22 +0800 Subject: [PATCH 24/49] feat(enterprise): add GDPR user/team data purge with audit redaction --- apps/api/src/routes/enterprise/gdpr.ts | 228 +++++++++++++++++++++++ tests/integration/gdpr-lifecycle.test.ts | 74 ++++++++ 2 files changed, 302 insertions(+) diff --git a/apps/api/src/routes/enterprise/gdpr.ts b/apps/api/src/routes/enterprise/gdpr.ts index 71147567..e12ed28a 100644 --- a/apps/api/src/routes/enterprise/gdpr.ts +++ b/apps/api/src/routes/enterprise/gdpr.ts @@ -1,12 +1,79 @@ 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 { 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. Delete jobs rows + await db.delete(schema.jobs).where(eq(schema.jobs.userId, userId)); + + // e. 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)); + + // f. Delete sessions + await db.delete(schema.sessions).where(eq(schema.sessions.userId, userId)); + + // g. Delete apiKeys + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, userId)); + + // h. Delete userPreferences + await db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId)); + + // i. 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( @@ -121,5 +188,166 @@ export async function registerGdprRoutes(app: FastifyInstance): Promise { }, ); + // 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; + + // 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/tests/integration/gdpr-lifecycle.test.ts b/tests/integration/gdpr-lifecycle.test.ts index dfe9dd6f..969a98ed 100644 --- a/tests/integration/gdpr-lifecycle.test.ts +++ b/tests/integration/gdpr-lifecycle.test.ts @@ -54,6 +54,15 @@ describe("GDPR data export", () => { 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({ @@ -87,3 +96,68 @@ describe("GDPR data export", () => { 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); + }); +}); From c7e9b1ddc6189f847633399a982ce88cb6ae4a85 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 21:20:12 +0800 Subject: [PATCH 25/49] feat(web): add storage dashboard and team retention settings Add per-team storage breakdown table to the usage section (from GET /api/v1/admin/usage teamStorage data). Add storage quota (MB) and retention (hours) inline fields to the teams section, saving via PUT /api/v1/teams/:id. Add i18n keys to all 21 locale files. --- .../components/settings/settings-dialog.tsx | 125 +++++++++++++++++- .../src/components/settings/usage-section.tsx | 38 ++++++ packages/shared/src/i18n/ar.ts | 10 ++ packages/shared/src/i18n/de.ts | 10 ++ packages/shared/src/i18n/en.ts | 10 ++ packages/shared/src/i18n/es.ts | 10 ++ packages/shared/src/i18n/fr.ts | 10 ++ packages/shared/src/i18n/hi.ts | 10 ++ packages/shared/src/i18n/id.ts | 10 ++ packages/shared/src/i18n/it.ts | 10 ++ packages/shared/src/i18n/ja.ts | 10 ++ packages/shared/src/i18n/ko.ts | 10 ++ packages/shared/src/i18n/nl.ts | 10 ++ packages/shared/src/i18n/pl.ts | 10 ++ packages/shared/src/i18n/pt-BR.ts | 10 ++ packages/shared/src/i18n/ru.ts | 10 ++ packages/shared/src/i18n/sv.ts | 10 ++ packages/shared/src/i18n/th.ts | 10 ++ packages/shared/src/i18n/tr.ts | 10 ++ packages/shared/src/i18n/uk.ts | 10 ++ packages/shared/src/i18n/vi.ts | 10 ++ packages/shared/src/i18n/zh-CN.ts | 10 ++ packages/shared/src/i18n/zh-TW.ts | 10 ++ 23 files changed, 372 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 4e8119ac..3ec9838d 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; } @@ -1916,6 +1918,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, ); @@ -2006,6 +2012,45 @@ 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 (
@@ -2095,8 +2140,8 @@ function TeamsSection() {
) : ( teams.map((tm) => ( +
{t.settings.teams.renameAction} +
+ {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} +

+
+
+
+ + +
+
+ )} + )) )}
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/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index d81b38eb..631512aa 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -2791,6 +2791,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 +2878,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", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index 7a7e4b84..bb4cc3b9 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -2810,6 +2810,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 +2898,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", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 5edf53b9..d80fc0ef 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -2756,6 +2756,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 +2843,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", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 848b37be..790416b5 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -2790,6 +2790,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 +2878,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", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 682db041..592e9b0f 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -2810,6 +2810,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 +2898,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", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 5e6e8f6d..98a8d230 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -2787,6 +2787,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 +2874,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 कुंजियां", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 00e2e6c5..2d63f720 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -2800,6 +2800,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 +2887,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", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index 19a24e13..2adf3efe 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -2803,6 +2803,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 +2891,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", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 1d0f9bea..5e0a3b5a 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -2758,6 +2758,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 +2845,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キー", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index ed782b84..24bec8cb 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -2742,6 +2742,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 +2830,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 키", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index de46c79d..54e7664e 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -2803,6 +2803,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 +2891,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", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 66b06f1c..7e4b6bf1 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -2807,6 +2807,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 +2895,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", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 7b372979..69283ed5 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -2801,6 +2801,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 +2889,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", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 812c88ea..df96e7fa 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -2801,6 +2801,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 +2888,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-ключи", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index b22e0a68..6e9f38f3 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -2799,6 +2799,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 +2886,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", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index 3afb3515..f7968917 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -2779,6 +2779,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 +2866,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", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index d725a8e0..769eea4a 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -2805,6 +2805,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 +2892,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ı", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index 3fc630d7..0639cf0c 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -2801,6 +2801,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 +2888,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-ключі", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 38a0f04d..edd1697b 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -2801,6 +2801,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 +2888,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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 7b668768..31a9a271 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -2731,6 +2731,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 +2818,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 密钥", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index cf89f8bb..b97bc1bf 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -2729,6 +2729,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 +2816,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金鑰", From 8eefa46981e49abc97b3047e253181c5bcd49725 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:04:03 +0800 Subject: [PATCH 26/49] chore: install @node-saml/node-saml and otpauth for Phase 3 identity --- apps/api/package.json | 2 + pnpm-lock.yaml | 2745 ++++++++--------------------------------- 2 files changed, 535 insertions(+), 2212 deletions(-) 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/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: {} From 7ed043ec5301106ad64ec0accc7ce411d818a469 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:04:14 +0800 Subject: [PATCH 27/49] feat(db): add identity columns (lastActivity, toolPermissions, TOTP) --- apps/api/drizzle/0004_handy_warhawk.sql | 5 + apps/api/drizzle/meta/0004_snapshot.json | 994 +++++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 9 +- apps/api/src/db/schema.ts | 5 + 4 files changed, 1012 insertions(+), 1 deletion(-) create mode 100644 apps/api/drizzle/0004_handy_warhawk.sql create mode 100644 apps/api/drizzle/meta/0004_snapshot.json 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/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 159ea0bb..69ce219c 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1781354362106, "tag": "0003_flimsy_dust", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1781359444186, + "tag": "0004_handy_warhawk", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index a791d12d..139aac86 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -38,6 +38,9 @@ export const users = pgTable("users", { 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 }), @@ -61,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()), @@ -161,6 +165,7 @@ export const roles = pgTable("roles", { 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 }) From 3cc4ef6895ddfc904252e4e99c48d5df41b4c17e Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:07:31 +0800 Subject: [PATCH 28/49] feat: add session idle timeout and concurrent session limit Idle timeout: reads `sessionIdleTimeoutMinutes` from settings, tracks last activity in Redis (with Postgres fallback on cache miss), and invalidates sessions that exceed the configured idle window. Concurrent session limit: reads `maxSessionsPerUser` from settings and evicts oldest sessions (FIFO) when a new login exceeds the cap. Both features are opt-in (disabled when value is 0 or absent). --- apps/api/src/lib/settings-helpers.ts | 23 +++++++++++++ apps/api/src/plugins/auth.ts | 51 +++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/lib/settings-helpers.ts diff --git a/apps/api/src/lib/settings-helpers.ts b/apps/api/src/lib/settings-helpers.ts new file mode 100644 index 00000000..5e7c394f --- /dev/null +++ b/apps/api/src/lib/settings-helpers.ts @@ -0,0 +1,23 @@ +import { eq } from "drizzle-orm"; +import { db, schema } from "../db/index.js"; + +/** + * 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; +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 4561312c..487e4e8f 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -1,11 +1,13 @@ 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 { sharedRedis } from "../jobs/connection.js"; import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; +import { getSettingNumber } from "../lib/settings-helpers.js"; import { getPermissions, requirePermission } from "../permissions.js"; const scryptAsync = promisify(scrypt); @@ -319,6 +321,23 @@ export async function authRoutes(app: FastifyInstance): Promise { expiresAt, }); + // ── 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)); + } + } + } + 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)); @@ -1028,6 +1047,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 = { From 9fa23f454322808d8c791b9d26b6bdbd1a5e9e73 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:11:03 +0800 Subject: [PATCH 29/49] feat: add per-tool permission model with category and per-tool modes --- apps/api/src/permissions.ts | 42 +++++++++++++++++++++++++++++ apps/api/src/routes/roles.ts | 24 +++++++++++++++-- apps/api/src/routes/tool-factory.ts | 9 +++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/api/src/permissions.ts b/apps/api/src/permissions.ts index b67c4803..755db47f 100644 --- a/apps/api/src/permissions.ts +++ b/apps/api/src/permissions.ts @@ -86,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/routes/roles.ts b/apps/api/src/routes/roles.ts index d8a1258a..c78cb37c 100644 --- a/apps/api/src/routes/roles.ts +++ b/apps/api/src/routes/roles.ts @@ -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 auditFromRequest(request)("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) { diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 17fc6f9f..587cffa7 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -201,6 +201,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"; From 8b349b0341b8cde518558c8ef83523263a95abae Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:12:38 +0800 Subject: [PATCH 30/49] feat: make password policy configurable via admin settings --- apps/api/src/lib/settings-helpers.ts | 18 ++++++++++++++++ apps/api/src/plugins/auth.ts | 31 ++++++++++++++++++---------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/api/src/lib/settings-helpers.ts b/apps/api/src/lib/settings-helpers.ts index 5e7c394f..3f89b07c 100644 --- a/apps/api/src/lib/settings-helpers.ts +++ b/apps/api/src/lib/settings-helpers.ts @@ -21,3 +21,21 @@ 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) return result[0].value; + } catch { + /* DB not ready or key absent -- fall through */ + } + return defaultValue; +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 487e4e8f..b564d852 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -7,7 +7,7 @@ import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { sharedRedis } from "../jobs/connection.js"; import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; -import { getSettingNumber } from "../lib/settings-helpers.js"; +import { getSettingNumber, getSettingString } from "../lib/settings-helpers.js"; import { getPermissions, requirePermission } from "../permissions.js"; const scryptAsync = promisify(scrypt); @@ -51,14 +51,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; } @@ -475,7 +484,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, @@ -590,7 +599,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, @@ -832,7 +841,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, From 6920035f5a6da0fbbcfb1b8086f5d6241c455c0b Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:20:22 +0800 Subject: [PATCH 31/49] refactor: extract external auth resolver from OIDC for SAML reuse Move user resolution logic (match by externalId, auto-link by email, auto-create with user limit check) into a shared module that both OIDC and SAML callbacks can use. Includes sanitizeUsername and findUniqueUsername helpers. Preserves all existing OIDC behavior and audit events. --- apps/api/src/lib/external-auth-resolver.ts | 214 ++++++++++++++++++ apps/api/src/plugins/oidc.ts | 165 ++------------ tests/unit/api/external-auth-resolver.test.ts | 72 ++++++ 3 files changed, 310 insertions(+), 141 deletions(-) create mode 100644 apps/api/src/lib/external-auth-resolver.ts create mode 100644 tests/unit/api/external-auth-resolver.test.ts 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/plugins/oidc.ts b/apps/api/src/plugins/oidc.ts index 0ef5d2fc..77782389 100644 --- a/apps/api/src/plugins/oidc.ts +++ b/apps/api/src/plugins/oidc.ts @@ -1,11 +1,10 @@ -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 { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; +import { resolveExternalUser, sanitizeUsername } from "../lib/external-auth-resolver.js"; import { createSessionToken } from "./auth.js"; // ── Types ───────────────────────────────────────────────────────── @@ -85,45 +84,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 { @@ -275,124 +235,47 @@ 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) { + 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 audit("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 audit("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 audit("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 audit("OIDC_LOGIN_SUCCESS", { - userId, - username: user?.username ?? username, + userId: resolvedUser.id, + username: resolvedUser.username, }); // 6. Set session cookie 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); + }); +}); From 54132d1833439425825954c4c55fba7975e24d0a Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:27:56 +0800 Subject: [PATCH 32/49] feat(enterprise): add SAML 2.0 SSO with SP-initiated login Implements SAML SSO using @node-saml/node-saml, gated behind SAML_ENABLED env var and the saml_sso enterprise license feature. - SAML env vars (entity ID, callback URL, IdP SSO URL, IdP cert, auto-create/auto-link users, default role, provider name, username/email attribute mapping) with validation in superRefine - SAML plugin with three routes: metadata (GET), login (GET), and ACS callback (POST with form-urlencoded content type parser) - Callback uses the shared external-auth resolver for user resolution (same pattern as OIDC: match/link/create/deny) - Auth config endpoint exposes samlEnabled and samlProviderName - Session loginMethod detection updated for SAML auth provider - Frontend login page shows SAML SSO button when enabled - i18n strings for SAML error messages across all 21 locales --- apps/api/src/index.ts | 23 +++- apps/api/src/lib/env.ts | 43 ++++++++ apps/api/src/plugins/auth.ts | 2 +- apps/api/src/plugins/saml.ts | 178 ++++++++++++++++++++++++++++++ apps/web/src/hooks/use-auth.ts | 10 ++ apps/web/src/pages/login-page.tsx | 35 ++++-- packages/shared/src/i18n/ar.ts | 5 + packages/shared/src/i18n/de.ts | 5 + packages/shared/src/i18n/en.ts | 5 + packages/shared/src/i18n/es.ts | 5 + packages/shared/src/i18n/fr.ts | 5 + packages/shared/src/i18n/hi.ts | 5 + packages/shared/src/i18n/id.ts | 5 + packages/shared/src/i18n/it.ts | 5 + packages/shared/src/i18n/ja.ts | 5 + packages/shared/src/i18n/ko.ts | 5 + packages/shared/src/i18n/nl.ts | 5 + packages/shared/src/i18n/pl.ts | 5 + packages/shared/src/i18n/pt-BR.ts | 5 + packages/shared/src/i18n/ru.ts | 5 + packages/shared/src/i18n/sv.ts | 5 + packages/shared/src/i18n/th.ts | 5 + packages/shared/src/i18n/tr.ts | 5 + packages/shared/src/i18n/uk.ts | 5 + packages/shared/src/i18n/vi.ts | 5 + packages/shared/src/i18n/zh-CN.ts | 5 + packages/shared/src/i18n/zh-TW.ts | 5 + 27 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/plugins/saml.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 67067bfb..7f148cb5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -30,6 +30,7 @@ import { ensureDefaultAdmin, } from "./plugins/auth.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 +40,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"; @@ -50,7 +52,6 @@ import { settingsRoutes } from "./routes/settings.js"; import { teamsRoutes } from "./routes/teams.js"; import { registerToolRoutes } from "./routes/tools/index.js"; import { userFileRoutes } from "./routes/user-files.js"; -import { registerEnterpriseRoutes } from "./routes/enterprise/index.js"; // Run before anything else try { @@ -302,6 +303,9 @@ await authRoutes(app); // OIDC routes await oidcRoutes(app); +// SAML routes +await registerSaml(app); + // File upload/download routes await fileRoutes(app); @@ -417,6 +421,23 @@ 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"; + } + return config; }); diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index a995c34d..b1af6212 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -79,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"), @@ -154,6 +174,29 @@ 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"], + }); + } + } }); export type Env = z.infer; diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index b564d852..67dc6d6e 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -458,7 +458,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, diff --git a/apps/api/src/plugins/saml.ts b/apps/api/src/plugins/saml.ts new file mode 100644 index 00000000..8c2121f3 --- /dev/null +++ b/apps/api/src/plugins/saml.ts @@ -0,0 +1,178 @@ +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 { 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`, + cert: 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"); + 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"); + 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) { + 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, + }); + + 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/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index 6e879871..a46ee62a 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -14,6 +14,8 @@ interface AuthState { analyticsConsentRemindAt: number | null; oidcEnabled: boolean; oidcProviderName: string | null; + samlEnabled: boolean; + samlProviderName: string | null; loginMethod: string | null; hasLocalPassword: boolean; } @@ -48,6 +50,8 @@ export function useAuth() { analyticsConsentRemindAt: null, oidcEnabled: false, oidcProviderName: null, + samlEnabled: false, + samlProviderName: null, loginMethod: null, hasLocalPassword: false, }); @@ -74,6 +78,8 @@ export function useAuth() { analyticsConsentRemindAt: null, oidcEnabled: false, oidcProviderName: null, + samlEnabled: false, + samlProviderName: null, loginMethod: null, hasLocalPassword: false, }); @@ -102,6 +108,8 @@ export function useAuth() { analyticsConsentRemindAt: session.user?.analyticsConsentRemindAt ?? null, oidcEnabled: config.oidcEnabled ?? false, oidcProviderName: config.oidcProviderName ?? null, + samlEnabled: config.samlEnabled ?? false, + samlProviderName: config.samlProviderName ?? null, loginMethod: session.user?.loginMethod ?? null, hasLocalPassword: session.user?.hasLocalPassword ?? false, }); @@ -120,6 +128,8 @@ export function useAuth() { analyticsConsentRemindAt: null, oidcEnabled: config.oidcEnabled ?? false, oidcProviderName: config.oidcProviderName ?? null, + samlEnabled: config.samlEnabled ?? false, + samlProviderName: config.samlProviderName ?? null, loginMethod: null, hasLocalPassword: false, }); diff --git a/apps/web/src/pages/login-page.tsx b/apps/web/src/pages/login-page.tsx index b98f3864..da85a24a 100644 --- a/apps/web/src/pages/login-page.tsx +++ b/apps/web/src/pages/login-page.tsx @@ -127,7 +127,7 @@ function LanguageSelector() { export function LoginPage() { const { t } = useTranslation(); - const { oidcEnabled, oidcProviderName } = useAuth(); + const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName } = useAuth(); const [searchParams] = useSearchParams(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -135,16 +135,19 @@ export function LoginPage() { const [loading, setLoading] = useState(false); 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]); @@ -230,19 +233,29 @@ export function LoginPage() { {loading ? t.auth.loggingIn : t.auth.loginButton} - {oidcEnabled && ( + {(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/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 631512aa..df142332 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -2997,6 +2997,11 @@ 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.", + methodSaml: "SAML", passwordManagedByProvider: "يتم إدارة تغيير كلمة المرور بواسطة مزود الهوية الخاص بك.", enterUsername: "أدخل اسم المستخدم", enterPassword: "أدخل كلمة المرور", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index bb4cc3b9..9e45d98a 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -3024,6 +3024,11 @@ 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.", + 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 d80fc0ef..db97d652 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -2963,6 +2963,11 @@ 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.", + 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 790416b5..c370befc 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -3002,6 +3002,11 @@ 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.", + 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 592e9b0f..0cf86294 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -3022,6 +3022,11 @@ 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.", + 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 98a8d230..d028c8e1 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -2994,6 +2994,11 @@ 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.", + methodSaml: "SAML", passwordManagedByProvider: "पासवर्ड बदलाव आपके आइडेंटिटी प्रोवाइडर द्वारा प्रबंधित किए जाते हैं।", enterUsername: "यूज़रनेम दर्ज करें", enterPassword: "अपना पासवर्ड दर्ज करें", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 2d63f720..0d0a1e0c 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -3010,6 +3010,11 @@ 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.", + 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 2adf3efe..3309c078 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -3016,6 +3016,11 @@ 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.", + 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 5e0a3b5a..a118686a 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -2967,6 +2967,11 @@ 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.", + methodSaml: "SAML", passwordManagedByProvider: "パスワードはIDプロバイダーで管理されています。", enterUsername: "ユーザー名を入力", enterPassword: "パスワードを入力", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 24bec8cb..63762d29 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -2952,6 +2952,11 @@ 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.", + methodSaml: "SAML", passwordManagedByProvider: "비밀번호는 ID 제공자에서 관리됩니다.", enterUsername: "사용자명 입력", enterPassword: "비밀번호 입력", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 54e7664e..9b3c4b16 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -3013,6 +3013,11 @@ 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.", + 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 7e4b6bf1..4df4644a 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -3020,6 +3020,11 @@ 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.", + 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 69283ed5..21c7b5e3 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -3013,6 +3013,11 @@ 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.", + 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 df96e7fa..d2dfaa85 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -3012,6 +3012,11 @@ 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.", + methodSaml: "SAML", passwordManagedByProvider: "Управление паролем осуществляется Вашим провайдером идентификации.", enterUsername: "Введите имя пользователя", enterPassword: "Введите пароль", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index 6e9f38f3..fa50c071 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -3008,6 +3008,11 @@ 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.", + 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 f7968917..4b906122 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -2985,6 +2985,11 @@ 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.", + methodSaml: "SAML", passwordManagedByProvider: "การเปลี่ยนรหัสผ่านจัดการโดยผู้ให้บริการยืนยันตัวตนของคุณ", enterUsername: "กรอกชื่อผู้ใช้", enterPassword: "กรอกรหัสผ่านของคุณ", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 769eea4a..ea5bf594 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -3016,6 +3016,11 @@ 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.", + 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 0639cf0c..e5100654 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -3013,6 +3013,11 @@ 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.", + methodSaml: "SAML", passwordManagedByProvider: "Керування паролем здійснюється Вашим постачальником ідентифікації.", enterUsername: "Введіть ім'я користувача", enterPassword: "Введіть пароль", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index edd1697b..a40a18dc 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -3008,6 +3008,11 @@ 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.", + 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 31a9a271..6aeb6da5 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -2937,6 +2937,11 @@ 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.", + methodSaml: "SAML", passwordManagedByProvider: "密码修改由您的身份提供商管理。", enterUsername: "输入用户名", enterPassword: "输入密码", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index b97bc1bf..dc5ca740 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -2935,6 +2935,11 @@ 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.", + methodSaml: "SAML", passwordManagedByProvider: "密碼由您的身分提供者管理。", enterUsername: "輸入使用者名稱", enterPassword: "輸入密碼", From 0c4468a004c228e860912dd8e3e662d270d3d113 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:32:16 +0800 Subject: [PATCH 33/49] feat(enterprise): add SSO enforcement mode with break-glass admin --- apps/api/src/index.ts | 3 +++ apps/api/src/plugins/auth.ts | 22 ++++++++++++++++++++ apps/web/src/hooks/use-auth.ts | 5 +++++ apps/web/src/pages/login-page.tsx | 34 ++++++++++++++++++++++++++++--- packages/shared/src/i18n/ar.ts | 1 + packages/shared/src/i18n/de.ts | 1 + packages/shared/src/i18n/en.ts | 1 + packages/shared/src/i18n/es.ts | 1 + packages/shared/src/i18n/fr.ts | 1 + packages/shared/src/i18n/hi.ts | 1 + packages/shared/src/i18n/id.ts | 1 + packages/shared/src/i18n/it.ts | 1 + packages/shared/src/i18n/ja.ts | 1 + packages/shared/src/i18n/ko.ts | 1 + packages/shared/src/i18n/nl.ts | 1 + packages/shared/src/i18n/pl.ts | 1 + packages/shared/src/i18n/pt-BR.ts | 1 + packages/shared/src/i18n/ru.ts | 1 + packages/shared/src/i18n/sv.ts | 1 + packages/shared/src/i18n/th.ts | 1 + packages/shared/src/i18n/tr.ts | 1 + packages/shared/src/i18n/uk.ts | 1 + packages/shared/src/i18n/vi.ts | 1 + packages/shared/src/i18n/zh-CN.ts | 1 + packages/shared/src/i18n/zh-TW.ts | 1 + 25 files changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7f148cb5..25a6e6ee 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ import { shouldRunStartupCleanup } from "./lib/cleanup.js"; import { buildCsp } from "./lib/csp.js"; import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js"; +import { getSettingString } from "./lib/settings-helpers.js"; import { requirePermission } from "./permissions.js"; import { authMiddleware, @@ -438,6 +439,8 @@ app.get("/api/v1/config/auth", async () => { config.samlLoginUrl = "/api/auth/saml/login"; } + config.ssoEnforced = (await getSettingString("ssoEnforcement", "false")) === "true"; + return config; }); diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 67dc6d6e..7a14732b 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -283,6 +283,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" }); diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index a46ee62a..c97b214b 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -16,6 +16,7 @@ interface AuthState { oidcProviderName: string | null; samlEnabled: boolean; samlProviderName: string | null; + ssoEnforced: boolean; loginMethod: string | null; hasLocalPassword: boolean; } @@ -52,6 +53,7 @@ export function useAuth() { oidcProviderName: null, samlEnabled: false, samlProviderName: null, + ssoEnforced: false, loginMethod: null, hasLocalPassword: false, }); @@ -80,6 +82,7 @@ export function useAuth() { oidcProviderName: null, samlEnabled: false, samlProviderName: null, + ssoEnforced: false, loginMethod: null, hasLocalPassword: false, }); @@ -110,6 +113,7 @@ export function useAuth() { 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, }); @@ -130,6 +134,7 @@ export function useAuth() { 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 da85a24a..3c541c89 100644 --- a/apps/web/src/pages/login-page.tsx +++ b/apps/web/src/pages/login-page.tsx @@ -127,7 +127,7 @@ function LanguageSelector() { export function LoginPage() { const { t } = useTranslation(); - const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName } = useAuth(); + const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName, ssoEnforced } = useAuth(); const [searchParams] = useSearchParams(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -191,7 +191,35 @@ 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} +

+
+ )} +