From 3420a000737d89a5c3c6c250d171d96126553beb Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Wed, 26 Nov 2025 10:38:15 +0100 Subject: [PATCH] feat: add CLI command for importing and exporting Pocket ID data (#998) Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/e2e-tests.yml | 17 +- .gitignore | 1 + backend/go.mod | 2 +- backend/internal/bootstrap/bootstrap.go | 131 +-- backend/internal/bootstrap/db_bootstrap.go | 102 +-- .../bootstrap/e2etest_router_bootstrap.go | 2 +- .../internal/bootstrap/router_bootstrap.go | 11 +- .../internal/bootstrap/services_bootstrap.go | 2 + backend/internal/cmds/export.go | 70 ++ backend/internal/cmds/import.go | 191 +++++ backend/internal/cmds/key_rotate.go | 3 +- backend/internal/cmds/root.go | 7 +- .../internal/controller/e2etest_controller.go | 7 +- backend/internal/model/types/date_time.go | 9 + backend/internal/service/app_lock_service.go | 296 +++++++ .../internal/service/app_lock_service_test.go | 189 +++++ backend/internal/service/e2etest_service.go | 93 +- backend/internal/service/export_service.go | 217 +++++ backend/internal/service/import_service.go | 264 ++++++ backend/internal/service/jwt_service.go | 9 +- backend/internal/utils/db_migration_util.go | 130 +++ backend/internal/utils/db_util.go | 116 +++ backend/internal/utils/servicerunner.go | 1 + backend/internal/utils/servicerunner_test.go | 20 + backend/resources/e2e-tests/database.json | 1 + backend/resources/files_test.go | 72 ++ ...251117141000_export_normalization.down.sql | 1 + ...20251117141000_export_normalization.up.sql | 1 + ...251117141000_export_normalization.down.sql | 133 +++ ...20251117141000_export_normalization.up.sql | 144 ++++ pnpm-lock.yaml | 796 +++++++++--------- scripts/docker/entrypoint.sh | 5 +- tests/package.json | 6 +- tests/playwright.config.ts | 14 +- tests/resources/export/database.json | 312 +++++++ .../export/uploads/application-images | 1 + .../uploads/profile-pictures/defaults/CF.png | Bin 0 -> 6189 bytes .../uploads/profile-pictures/defaults/TC.png | Bin 0 -> 5818 bytes .../images}/cloud-logo.png | Bin .../images}/cloud-logo.svg | 0 tests/{assets => resources/images}/clouds.jpg | Bin .../images}/pingvin-share-logo.png | Bin .../images}/w3-schools-favicon.ico | Bin tests/setup/docker-compose-postgres.yml | 5 +- tests/setup/docker-compose-s3.yml | 3 + tests/setup/docker-compose.yml | 7 +- tests/specs/application-configuration.spec.ts | 14 +- tests/specs/cli.spec.ts | 366 ++++++++ tests/specs/{ => fixtures}/auth.setup.ts | 7 +- tests/specs/fixtures/global.setup.ts | 8 + tests/specs/fixtures/global.teardown.ts | 8 + tests/specs/oidc-client-settings.spec.ts | 8 +- tests/specs/user-signup.spec.ts | 2 +- tests/tsconfig.json | 6 +- tests/utils/cleanup.util.ts | 4 +- tests/utils/fs.util.ts | 7 + 56 files changed, 3178 insertions(+), 643 deletions(-) create mode 100644 backend/internal/cmds/export.go create mode 100644 backend/internal/cmds/import.go create mode 100644 backend/internal/service/app_lock_service.go create mode 100644 backend/internal/service/app_lock_service_test.go create mode 100644 backend/internal/service/export_service.go create mode 100644 backend/internal/service/import_service.go create mode 100644 backend/internal/utils/db_migration_util.go create mode 100644 backend/internal/utils/db_util.go create mode 120000 backend/resources/e2e-tests/database.json create mode 100644 backend/resources/files_test.go create mode 100644 backend/resources/migrations/postgres/20251117141000_export_normalization.down.sql create mode 100644 backend/resources/migrations/postgres/20251117141000_export_normalization.up.sql create mode 100644 backend/resources/migrations/sqlite/20251117141000_export_normalization.down.sql create mode 100644 backend/resources/migrations/sqlite/20251117141000_export_normalization.up.sql create mode 100644 tests/resources/export/database.json create mode 120000 tests/resources/export/uploads/application-images create mode 100644 tests/resources/export/uploads/profile-pictures/defaults/CF.png create mode 100644 tests/resources/export/uploads/profile-pictures/defaults/TC.png rename tests/{assets => resources/images}/cloud-logo.png (100%) rename tests/{assets => resources/images}/cloud-logo.svg (100%) rename tests/{assets => resources/images}/clouds.jpg (100%) rename tests/{assets => resources/images}/pingvin-share-logo.png (100%) rename tests/{assets => resources/images}/w3-schools-favicon.ico (100%) create mode 100644 tests/specs/cli.spec.ts rename tests/specs/{ => fixtures}/auth.setup.ts (56%) create mode 100644 tests/specs/fixtures/global.setup.ts create mode 100644 tests/specs/fixtures/global.teardown.ts create mode 100644 tests/utils/fs.util.ts diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index bee94675..b87b0d32 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -171,7 +171,7 @@ jobs: run: | DOCKER_COMPOSE_FILE=docker-compose.yml - export FILE_BACKEND="${{ matrix.storage }}" + echo "FILE_BACKEND=${{ matrix.storage }}" > .env if [ "${{ matrix.db }}" = "postgres" ]; then DOCKER_COMPOSE_FILE=docker-compose-postgres.yml elif [ "${{ matrix.storage }}" = "s3" ]; then @@ -179,7 +179,20 @@ jobs: fi docker compose -f "$DOCKER_COMPOSE_FILE" up -d - docker compose -f "$DOCKER_COMPOSE_FILE" logs -f pocket-id &> /tmp/backend.log & + + { + LOG_FILE="/tmp/backend.log" + while true; do + CID=$(docker compose -f "$DOCKER_COMPOSE_FILE" ps -q pocket-id) + if [ -n "$CID" ]; then + echo "[$(date)] Attaching logs for $CID" >> "$LOG_FILE" + docker logs -f --since=0 "$CID" >> "$LOG_FILE" 2>&1 + else + echo "[$(date)] Container not yet running…" >> "$LOG_FILE" + fi + sleep 1 + done + } & - name: Run Playwright tests working-directory: ./tests diff --git a/.gitignore b/.gitignore index abf848c2..7d3bbc73 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ node_modules /backend/bin pocket-id /tests/test-results/*.json +.tmp/ # OS .DS_Store diff --git a/backend/go.mod b/backend/go.mod index bb760f45..6475c282 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,7 +14,6 @@ require ( github.com/disintegration/imaging v1.6.2 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 - github.com/fxamacker/cbor/v2 v2.9.0 github.com/gin-contrib/slog v1.2.0 github.com/gin-gonic/gin v1.11.0 github.com/glebarez/go-sqlite v1.22.0 @@ -84,6 +83,7 @@ require ( github.com/disintegration/gift v1.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index e59a1562..733add97 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -7,6 +7,7 @@ import ( "time" _ "github.com/golang-migrate/migrate/v4/source/file" + "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/job" @@ -15,6 +16,16 @@ import ( ) func Bootstrap(ctx context.Context) error { + var shutdownFns []utils.Service + defer func() { //nolint:contextcheck + // Invoke all shutdown functions on exit + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := utils.NewServiceRunner(shutdownFns...).Run(shutdownCtx); err != nil { + slog.Error("Error during graceful shutdown", "error", err) + } + }() + // Initialize the observability stack, including the logger, distributed tracing, and metrics shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled) if err != nil { @@ -22,15 +33,80 @@ func Bootstrap(ctx context.Context) error { } slog.InfoContext(ctx, "Pocket ID is starting") - // Connect to the database db, err := NewDatabase() if err != nil { return fmt.Errorf("failed to initialize database: %w", err) } - // Initialize the file storage backend - var fileStorage storage.FileStorage + fileStorage, err := InitStorage(ctx, db) + if err != nil { + return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err) + } + imageExtensions, err := initApplicationImages(ctx, fileStorage) + if err != nil { + return fmt.Errorf("failed to initialize application images: %w", err) + } + + // Create all services + svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage) + if err != nil { + return fmt.Errorf("failed to initialize services: %w", err) + } + + waitUntil, err := svc.appLockService.Acquire(ctx, false) + if err != nil { + return fmt.Errorf("failed to acquire application lock: %w", err) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Until(waitUntil)): + } + + shutdownFn := func(shutdownCtx context.Context) error { + sErr := svc.appLockService.Release(shutdownCtx) + if sErr != nil { + return fmt.Errorf("failed to release application lock: %w", sErr) + } + return nil + } + shutdownFns = append(shutdownFns, shutdownFn) + + // Init the job scheduler + scheduler, err := job.NewScheduler() + if err != nil { + return fmt.Errorf("failed to create job scheduler: %w", err) + } + err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler) + if err != nil { + return fmt.Errorf("failed to register scheduled jobs: %w", err) + } + + // Init the router + router, err := initRouter(db, svc) + if err != nil { + return fmt.Errorf("failed to initialize router: %w", err) + } + + // Run all background services + // This call blocks until the context is canceled + services := []utils.Service{svc.appLockService.RunRenewal, router} + + if common.EnvConfig.AppEnv != "test" { + services = append(services, scheduler.Run) + } + + err = utils.NewServiceRunner(services...).Run(ctx) + if err != nil { + return fmt.Errorf("failed to run services: %w", err) + } + + return nil +} + +func InitStorage(ctx context.Context, db *gorm.DB) (fileStorage storage.FileStorage, err error) { switch common.EnvConfig.FileBackend { case storage.TypeFileSystem: fileStorage, err = storage.NewFilesystemStorage(common.EnvConfig.UploadPath) @@ -52,53 +128,8 @@ func Bootstrap(ctx context.Context) error { err = fmt.Errorf("unknown file storage backend: %s", common.EnvConfig.FileBackend) } if err != nil { - return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err) + return fileStorage, err } - imageExtensions, err := initApplicationImages(ctx, fileStorage) - if err != nil { - return fmt.Errorf("failed to initialize application images: %w", err) - } - - // Create all services - svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage) - if err != nil { - return fmt.Errorf("failed to initialize services: %w", err) - } - - // Init the job scheduler - scheduler, err := job.NewScheduler() - if err != nil { - return fmt.Errorf("failed to create job scheduler: %w", err) - } - err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler) - if err != nil { - return fmt.Errorf("failed to register scheduled jobs: %w", err) - } - - // Init the router - router := initRouter(db, svc) - - // Run all background services - // This call blocks until the context is canceled - err = utils. - NewServiceRunner(router, scheduler.Run). - Run(ctx) - if err != nil { - return fmt.Errorf("failed to run services: %w", err) - } - - // Invoke all shutdown functions - // We give these a timeout of 5s - // Note: we use a background context because the run context has been canceled already - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutdownCancel() - err = utils. - NewServiceRunner(shutdownFns...). - Run(shutdownCtx) //nolint:contextcheck - if err != nil { - slog.Error("Error shutting down services", slog.Any("error", err)) - } - - return nil + return fileStorage, nil } diff --git a/backend/internal/bootstrap/db_bootstrap.go b/backend/internal/bootstrap/db_bootstrap.go index 0f756941..4fb7e87a 100644 --- a/backend/internal/bootstrap/db_bootstrap.go +++ b/backend/internal/bootstrap/db_bootstrap.go @@ -12,12 +12,7 @@ import ( "time" "github.com/glebarez/sqlite" - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database" - postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres" - sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" _ "github.com/golang-migrate/migrate/v4/source/github" - "github.com/golang-migrate/migrate/v4/source/iofs" slogGorm "github.com/orandin/slog-gorm" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -26,11 +21,10 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/utils" sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite" - "github.com/pocket-id/pocket-id/backend/resources" ) func NewDatabase() (db *gorm.DB, err error) { - db, err = connectDatabase() + db, err = ConnectDatabase() if err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } @@ -39,105 +33,15 @@ func NewDatabase() (db *gorm.DB, err error) { return nil, fmt.Errorf("failed to get sql.DB: %w", err) } - // Choose the correct driver for the database provider - var driver database.Driver - switch common.EnvConfig.DbProvider { - case common.DbProviderSqlite: - driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{ - NoTxWrap: true, - }) - case common.DbProviderPostgres: - driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{}) - default: - // Should never happen at this point - return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider) - } - if err != nil { - return nil, fmt.Errorf("failed to create migration driver: %w", err) - } - // Run migrations - if err := migrateDatabase(driver); err != nil { + if err := utils.MigrateDatabase(sqlDb); err != nil { return nil, fmt.Errorf("failed to run migrations: %w", err) } return db, nil } -func migrateDatabase(driver database.Driver) error { - // Embedded migrations via iofs - path := "migrations/" + string(common.EnvConfig.DbProvider) - source, err := iofs.New(resources.FS, path) - if err != nil { - return fmt.Errorf("failed to create embedded migration source: %w", err) - } - - m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver) - if err != nil { - return fmt.Errorf("failed to create migration instance: %w", err) - } - - requiredVersion, err := getRequiredMigrationVersion(path) - if err != nil { - return fmt.Errorf("failed to get last migration version: %w", err) - } - - currentVersion, _, _ := m.Version() - if currentVersion > requiredVersion { - slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion))) - if !common.EnvConfig.AllowDowngrade { - return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion) - } - slog.Info("Fetching migrations from GitHub to handle possible downgrades") - return migrateDatabaseFromGitHub(driver, requiredVersion) - } - - if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) { - return fmt.Errorf("failed to apply embedded migrations: %w", err) - } - return nil -} - -func migrateDatabaseFromGitHub(driver database.Driver, version uint) error { - srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider) - - m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver) - if err != nil { - return fmt.Errorf("failed to create GitHub migration instance: %w", err) - } - - if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) { - return fmt.Errorf("failed to apply GitHub migrations: %w", err) - } - return nil -} - -// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found. -func getRequiredMigrationVersion(path string) (uint, error) { - entries, err := resources.FS.ReadDir(path) - if err != nil { - return 0, fmt.Errorf("failed to read migration directory: %w", err) - } - - var maxVersion uint - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - var version uint - n, err := fmt.Sscanf(name, "%d_", &version) - if err == nil && n == 1 { - if version > maxVersion { - maxVersion = version - } - } - } - - return maxVersion, nil -} - -func connectDatabase() (db *gorm.DB, err error) { +func ConnectDatabase() (db *gorm.DB, err error) { var dialector gorm.Dialector // Choose the correct database provider diff --git a/backend/internal/bootstrap/e2etest_router_bootstrap.go b/backend/internal/bootstrap/e2etest_router_bootstrap.go index b4eb9fc7..b87d4644 100644 --- a/backend/internal/bootstrap/e2etest_router_bootstrap.go +++ b/backend/internal/bootstrap/e2etest_router_bootstrap.go @@ -17,7 +17,7 @@ import ( func init() { registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){ func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) { - testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.fileStorage) + testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage) if err != nil { slog.Error("Failed to initialize test service", slog.Any("error", err)) os.Exit(1) diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 9153bae9..f2229114 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -29,16 +29,7 @@ import ( // This is used to register additional controllers for tests var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) -func initRouter(db *gorm.DB, svc *services) utils.Service { - runner, err := initRouterInternal(db, svc) - if err != nil { - slog.Error("Failed to init router", "error", err) - os.Exit(1) - } - return runner -} - -func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) { +func initRouter(db *gorm.DB, svc *services) (utils.Service, error) { // Set the appropriate Gin mode based on the environment switch common.EnvConfig.AppEnv { case common.AppEnvProduction: diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 21254627..e5fc805a 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -27,6 +27,7 @@ type services struct { apiKeyService *service.ApiKeyService versionService *service.VersionService fileStorage storage.FileStorage + appLockService *service.AppLockService } // Initializes all services @@ -40,6 +41,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima svc.fileStorage = fileStorage svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage) + svc.appLockService = service.NewAppLockService(db) svc.emailService, err = service.NewEmailService(db, svc.appConfigService) if err != nil { diff --git a/backend/internal/cmds/export.go b/backend/internal/cmds/export.go new file mode 100644 index 00000000..55194e2f --- /dev/null +++ b/backend/internal/cmds/export.go @@ -0,0 +1,70 @@ +package cmds + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/pocket-id/pocket-id/backend/internal/bootstrap" + "github.com/pocket-id/pocket-id/backend/internal/service" + "github.com/spf13/cobra" +) + +type exportFlags struct { + Path string +} + +func init() { + var flags exportFlags + + exportCmd := &cobra.Command{ + Use: "export", + Short: "Exports all data of Pocket ID into a ZIP file", + RunE: func(cmd *cobra.Command, args []string) error { + return runExport(cmd.Context(), flags) + }, + } + + exportCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to export the data to, or '-' to write to stdout") + + rootCmd.AddCommand(exportCmd) +} + +// runExport orchestrates the export flow +func runExport(ctx context.Context, flags exportFlags) error { + db, err := bootstrap.NewDatabase() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + storage, err := bootstrap.InitStorage(ctx, db) + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + exportService := service.NewExportService(db, storage) + + var w io.Writer + if flags.Path == "-" { + w = os.Stdout + } else { + file, err := os.Create(flags.Path) + if err != nil { + return fmt.Errorf("failed to create export file: %w", err) + } + defer file.Close() + + w = file + } + + if err := exportService.ExportToZip(ctx, w); err != nil { + return fmt.Errorf("failed to export data: %w", err) + } + + if flags.Path != "-" { + fmt.Printf("Exported data to %s\n", flags.Path) + } + + return nil +} diff --git a/backend/internal/cmds/import.go b/backend/internal/cmds/import.go new file mode 100644 index 00000000..974d049e --- /dev/null +++ b/backend/internal/cmds/import.go @@ -0,0 +1,191 @@ +package cmds + +import ( + "archive/zip" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/bootstrap" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/service" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +type importFlags struct { + Path string + Yes bool + ForcefullyAcquireLock bool +} + +func init() { + var flags importFlags + + importCmd := &cobra.Command{ + Use: "import", + Short: "Imports all data of Pocket ID from a ZIP file", + RunE: func(cmd *cobra.Command, args []string) error { + return runImport(cmd.Context(), flags) + }, + } + + importCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to import the data from, or '-' to read from stdin") + importCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Skip confirmation prompts") + importCmd.Flags().BoolVarP(&flags.ForcefullyAcquireLock, "forcefully-acquire-lock", "", false, "Forcefully acquire the application lock by terminating the Pocket ID instance") + + rootCmd.AddCommand(importCmd) +} + +// runImport handles the high-level orchestration of the import process +func runImport(ctx context.Context, flags importFlags) error { + if !flags.Yes { + ok, err := askForConfirmation() + if err != nil { + return fmt.Errorf("failed to get confirmation: %w", err) + } + if !ok { + fmt.Println("Aborted") + os.Exit(1) + } + } + + var ( + zipReader *zip.ReadCloser + cleanup func() + err error + ) + + if flags.Path == "-" { + zipReader, cleanup, err = readZipFromStdin() + defer cleanup() + } else { + zipReader, err = zip.OpenReader(flags.Path) + } + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + defer zipReader.Close() + + db, err := bootstrap.ConnectDatabase() + if err != nil { + return err + } + + err = acquireImportLock(ctx, db, flags.ForcefullyAcquireLock) + if err != nil { + return err + } + + storage, err := bootstrap.InitStorage(ctx, db) + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + importService := service.NewImportService(db, storage) + err = importService.ImportFromZip(ctx, &zipReader.Reader) + if err != nil { + return fmt.Errorf("failed to import data from zip: %w", err) + } + + fmt.Println("Import completed successfully.") + return nil +} + +func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error { + // Check if the kv table exists, in case we are starting from an empty database + exists, err := utils.DBTableExists(db, "kv") + if err != nil { + return fmt.Errorf("failed to check if kv table exists: %w", err) + } + if !exists { + // This either means the database is empty, or the import is into an old version of PocketID that doesn't support locks + // In either case, there's no lock to acquire + fmt.Println("Could not acquire a lock because the 'kv' table does not exist. This is fine if you're importing into a new database, but make sure that there isn't an instance of Pocket ID currently running and using the same database.") + return nil + } + + // Note that we do not call a deferred Release if the data was imported + // This is because we are overriding the contents of the database, so the lock is automatically lost + appLockService := service.NewAppLockService(db) + + opCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + waitUntil, err := appLockService.Acquire(opCtx, force) + if err != nil { + if errors.Is(err, service.ErrLockUnavailable) { + //nolint:staticcheck + return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance") + } + return fmt.Errorf("failed to acquire application lock: %w", err) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Until(waitUntil)): + } + + return nil +} + +func askForConfirmation() (bool, error) { + fmt.Println("WARNING: This feature is experimental and may not work correctly. Please create a backup before proceeding and report any issues you encounter.") + fmt.Println() + fmt.Println("WARNING: Import will erase all existing data at the following locations:") + fmt.Printf("Database: %s\n", absolutePathOrOriginal(common.EnvConfig.DbConnectionString)) + fmt.Printf("Uploads Path: %s\n", absolutePathOrOriginal(common.EnvConfig.UploadPath)) + + ok, err := utils.PromptForConfirmation("Do you want to continue?") + if err != nil { + return false, err + } + + return ok, nil +} + +// absolutePathOrOriginal returns the absolute path of the given path, or the original if it fails +func absolutePathOrOriginal(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return path + } + return abs +} + +func readZipFromStdin() (*zip.ReadCloser, func(), error) { + tmpFile, err := os.CreateTemp("", "pocket-id-import-*.zip") + if err != nil { + return nil, nil, fmt.Errorf("failed to create temporary file: %w", err) + } + + cleanup := func() { + _ = os.Remove(tmpFile.Name()) + } + + if _, err := io.Copy(tmpFile, os.Stdin); err != nil { + tmpFile.Close() + cleanup() + return nil, nil, fmt.Errorf("failed to read data from stdin: %w", err) + } + + if err := tmpFile.Close(); err != nil { + cleanup() + return nil, nil, fmt.Errorf("failed to close temporary file: %w", err) + } + + r, err := zip.OpenReader(tmpFile.Name()) + if err != nil { + cleanup() + return nil, nil, err + } + + return r, cleanup, nil +} diff --git a/backend/internal/cmds/key_rotate.go b/backend/internal/cmds/key_rotate.go index 1a219b7d..5225c623 100644 --- a/backend/internal/cmds/key_rotate.go +++ b/backend/internal/cmds/key_rotate.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "strings" "github.com/lestrrat-go/jwx/v3/jwa" @@ -78,7 +79,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig } if !ok { fmt.Println("Aborted") - return nil + os.Exit(1) } } diff --git a/backend/internal/cmds/root.go b/backend/internal/cmds/root.go index f3488cbb..01391ec2 100644 --- a/backend/internal/cmds/root.go +++ b/backend/internal/cmds/root.go @@ -12,9 +12,10 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "pocket-id", - Short: "A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.", - Long: "By default, this command starts the pocket-id server.", + Use: "pocket-id", + Short: "A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.", + Long: "By default, this command starts the pocket-id server.", + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { // Start the server err := bootstrap.Bootstrap(cmd.Context()) diff --git a/backend/internal/controller/e2etest_controller.go b/backend/internal/controller/e2etest_controller.go index 54d74e2c..62937c66 100644 --- a/backend/internal/controller/e2etest_controller.go +++ b/backend/internal/controller/e2etest_controller.go @@ -40,6 +40,11 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) { return } + if err := tc.TestService.ResetLock(c.Request.Context()); err != nil { + _ = c.Error(err) + return + } + if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil { _ = c.Error(err) return @@ -69,8 +74,6 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) { } } - tc.TestService.SetJWTKeys() - c.Status(http.StatusNoContent) } diff --git a/backend/internal/model/types/date_time.go b/backend/internal/model/types/date_time.go index 6cea0268..bcc5f9f5 100644 --- a/backend/internal/model/types/date_time.go +++ b/backend/internal/model/types/date_time.go @@ -11,6 +11,15 @@ import ( // DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres type DateTime time.Time //nolint:recvcheck +func DateTimeFromString(str string) (DateTime, error) { + t, err := time.Parse(time.RFC3339Nano, str) + if err != nil { + return DateTime{}, fmt.Errorf("failed to parse date string: %w", err) + } + + return DateTime(t), nil +} + func (date *DateTime) Scan(value any) (err error) { switch v := value.(type) { case time.Time: diff --git a/backend/internal/service/app_lock_service.go b/backend/internal/service/app_lock_service.go new file mode 100644 index 00000000..339e41cd --- /dev/null +++ b/backend/internal/service/app_lock_service.go @@ -0,0 +1,296 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "time" + + "github.com/google/uuid" + "github.com/pocket-id/pocket-id/backend/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrLockUnavailable = errors.New("lock is already held by another process") + ErrLockLost = errors.New("lock ownership lost") +) + +const ( + ttl = 30 * time.Second + renewInterval = 20 * time.Second + renewRetries = 3 + lockKey = "application_lock" +) + +type AppLockService struct { + db *gorm.DB + lockID string + processID int64 + hostID string +} + +func NewAppLockService(db *gorm.DB) *AppLockService { + host, err := os.Hostname() + if err != nil || host == "" { + host = "unknown-host" + } + + return &AppLockService{ + db: db, + processID: int64(os.Getpid()), + hostID: host, + lockID: uuid.NewString(), + } +} + +type lockValue struct { + ProcessID int64 `json:"process_id"` + HostID string `json:"host_id"` + LockID string `json:"lock_id"` + ExpiresAt int64 `json:"expires_at"` +} + +func (lv *lockValue) Marshal() (string, error) { + data, err := json.Marshal(lv) + if err != nil { + return "", err + } + return string(data), nil +} + +func (lv *lockValue) Unmarshal(raw string) error { + if raw == "" { + return nil + } + return json.Unmarshal([]byte(raw), lv) +} + +// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner. +// If the lock is forcefully acquired, it blocks until the previous lock has expired. +func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + var prevLockRaw string + err = tx. + WithContext(ctx). + Model(&model.KV{}). + Where("key = ?", lockKey). + Clauses(clause.Locking{Strength: "UPDATE"}). + Select("value"). + Scan(&prevLockRaw). + Error + if err != nil { + return time.Time{}, fmt.Errorf("query existing lock: %w", err) + } + + var prevLock lockValue + if prevLockRaw != "" { + if err := prevLock.Unmarshal(prevLockRaw); err != nil { + return time.Time{}, fmt.Errorf("decode existing lock value: %w", err) + } + } + + now := time.Now() + nowUnix := now.Unix() + + value := lockValue{ + ProcessID: s.processID, + HostID: s.hostID, + LockID: s.lockID, + ExpiresAt: now.Add(ttl).Unix(), + } + raw, err := value.Marshal() + if err != nil { + return time.Time{}, fmt.Errorf("encode lock value: %w", err) + } + + var query string + switch s.db.Name() { + case "sqlite": + query = ` + INSERT INTO kv (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value + WHERE (json_extract(kv.value, '$.expires_at') < ?) OR ? + ` + case "postgres": + query = ` + INSERT INTO kv (key, value) + VALUES ($1, $2) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value + WHERE ((kv.value::json->>'expires_at')::bigint < $3) OR ($4::boolean IS TRUE) + ` + default: + return time.Time{}, fmt.Errorf("unsupported database dialect: %s", s.db.Name()) + } + + res := tx.WithContext(ctx).Exec(query, lockKey, raw, nowUnix, force) + if res.Error != nil { + return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error) + } + + if err := tx.Commit().Error; err != nil { + return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err) + } + + // If there is a lock that is not expired and force is false, no rows will be affected + if res.RowsAffected == 0 { + return time.Time{}, ErrLockUnavailable + } + + if force && prevLock.ExpiresAt > nowUnix && prevLock.LockID != s.lockID { + waitUntil = time.Unix(prevLock.ExpiresAt, 0) + } + + attrs := []any{ + slog.Int64("process_id", s.processID), + slog.String("host_id", s.hostID), + } + if wait := time.Until(waitUntil); wait > 0 { + attrs = append(attrs, slog.Duration("wait_before_proceeding", wait)) + } + slog.Info("Acquired application lock", attrs...) + + return waitUntil, nil +} + +// RunRenewal keeps renewing the lock until the context is canceled. +func (s *AppLockService) RunRenewal(ctx context.Context) error { + ticker := time.NewTicker(renewInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := s.renew(ctx); err != nil { + return fmt.Errorf("renew lock: %w", err) + } + } + } +} + +// Release releases the lock if it is held by this process. +func (s *AppLockService) Release(ctx context.Context) error { + opCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + var query string + switch s.db.Name() { + case "sqlite": + query = ` + DELETE FROM kv + WHERE key = ? + AND json_extract(value, '$.lock_id') = ? + ` + case "postgres": + query = ` + DELETE FROM kv + WHERE key = $1 + AND value::json->>'lock_id' = $2 + ` + default: + return fmt.Errorf("unsupported database dialect: %s", s.db.Name()) + } + + res := s.db.WithContext(opCtx).Exec(query, lockKey, s.lockID) + if res.Error != nil { + return fmt.Errorf("release lock failed: %w", res.Error) + } + + if res.RowsAffected == 0 { + slog.Warn("Application lock not held by this process, cannot release", + slog.Int64("process_id", s.processID), + slog.String("host_id", s.hostID), + ) + } + + slog.Info("Released application lock", + slog.Int64("process_id", s.processID), + slog.String("host_id", s.hostID), + ) + return nil +} + +// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts). +func (s *AppLockService) renew(ctx context.Context) error { + var lastErr error + for attempt := 1; attempt <= renewRetries; attempt++ { + now := time.Now() + nowUnix := now.Unix() + expiresAt := now.Add(ttl).Unix() + + value := lockValue{ + LockID: s.lockID, + ProcessID: s.processID, + HostID: s.hostID, + ExpiresAt: expiresAt, + } + raw, err := value.Marshal() + if err != nil { + return fmt.Errorf("encode lock value: %w", err) + } + + var query string + switch s.db.Name() { + case "sqlite": + query = ` + UPDATE kv + SET value = ? + WHERE key = ? + AND json_extract(value, '$.lock_id') = ? + AND json_extract(value, '$.expires_at') > ? + ` + case "postgres": + query = ` + UPDATE kv + SET value = $1 + WHERE key = $2 + AND value::json->>'lock_id' = $3 + AND ((value::json->>'expires_at')::bigint > $4) + ` + default: + return fmt.Errorf("unsupported database dialect: %s", s.db.Name()) + } + + opCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix) + cancel() + + switch { + case res.Error != nil: + lastErr = fmt.Errorf("lock renewal failed: %w", res.Error) + case res.RowsAffected == 0: + // Must be after checking res.Error + return ErrLockLost + default: + slog.Debug("Renewed application lock", + slog.Int64("process_id", s.processID), + slog.String("host_id", s.hostID), + ) + return nil + } + + // Wait before next attempt or cancel if context is done + if attempt < renewRetries { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(1 * time.Second): + } + } + } + + return lastErr +} diff --git a/backend/internal/service/app_lock_service_test.go b/backend/internal/service/app_lock_service_test.go new file mode 100644 index 00000000..95b22f51 --- /dev/null +++ b/backend/internal/service/app_lock_service_test.go @@ -0,0 +1,189 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +func newTestAppLockService(t *testing.T, db *gorm.DB) *AppLockService { + t.Helper() + + return &AppLockService{ + db: db, + processID: 1, + hostID: "test-host", + lockID: "a13c7673-c7ae-49f1-9112-2cd2d0d4b0c1", + } +} + +func insertLock(t *testing.T, db *gorm.DB, value lockValue) { + t.Helper() + + raw, err := value.Marshal() + require.NoError(t, err) + + err = db.Create(&model.KV{Key: lockKey, Value: &raw}).Error + require.NoError(t, err) +} + +func readLockValue(t *testing.T, db *gorm.DB) lockValue { + t.Helper() + + var row model.KV + err := db.Take(&row, "key = ?", lockKey).Error + require.NoError(t, err) + + require.NotNil(t, row.Value) + + var value lockValue + err = value.Unmarshal(*row.Value) + require.NoError(t, err) + + return value +} + +func TestAppLockServiceAcquire(t *testing.T) { + t.Run("creates new lock when none exists", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + _, err := service.Acquire(context.Background(), false) + require.NoError(t, err) + + stored := readLockValue(t, db) + require.Equal(t, service.processID, stored.ProcessID) + require.Equal(t, service.hostID, stored.HostID) + require.Greater(t, stored.ExpiresAt, time.Now().Unix()) + }) + + t.Run("returns ErrLockUnavailable when lock held by another process", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + existing := lockValue{ + ProcessID: 99, + HostID: "other-host", + ExpiresAt: time.Now().Add(ttl).Unix(), + } + insertLock(t, db, existing) + + _, err := service.Acquire(context.Background(), false) + require.ErrorIs(t, err, ErrLockUnavailable) + + current := readLockValue(t, db) + require.Equal(t, existing, current) + }) + + t.Run("force acquisition steals lock", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + insertLock(t, db, lockValue{ + ProcessID: 99, + HostID: "other-host", + ExpiresAt: time.Now().Unix(), + }) + + _, err := service.Acquire(context.Background(), true) + require.NoError(t, err) + + stored := readLockValue(t, db) + require.Equal(t, service.processID, stored.ProcessID) + require.Equal(t, service.hostID, stored.HostID) + require.Greater(t, stored.ExpiresAt, time.Now().Unix()) + }) +} + +func TestAppLockServiceRelease(t *testing.T) { + t.Run("removes owned lock", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + _, err := service.Acquire(context.Background(), false) + require.NoError(t, err) + + err = service.Release(context.Background()) + require.NoError(t, err) + + var row model.KV + err = db.Take(&row, "key = ?", lockKey).Error + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) + + t.Run("ignores lock held by another owner", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + existing := lockValue{ + ProcessID: 2, + HostID: "other-host", + ExpiresAt: time.Now().Add(ttl).Unix(), + } + insertLock(t, db, existing) + + err := service.Release(context.Background()) + require.NoError(t, err) + + stored := readLockValue(t, db) + require.Equal(t, existing, stored) + }) +} + +func TestAppLockServiceRenew(t *testing.T) { + t.Run("extends expiration when lock is still owned", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + _, err := service.Acquire(context.Background(), false) + require.NoError(t, err) + + before := readLockValue(t, db) + + err = service.renew(context.Background()) + require.NoError(t, err) + + after := readLockValue(t, db) + require.Equal(t, service.processID, after.ProcessID) + require.Equal(t, service.hostID, after.HostID) + require.GreaterOrEqual(t, after.ExpiresAt, before.ExpiresAt) + }) + + t.Run("returns ErrLockLost when lock is missing", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + err := service.renew(context.Background()) + require.ErrorIs(t, err, ErrLockLost) + }) + + t.Run("returns ErrLockLost when ownership changed", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + _, err := service.Acquire(context.Background(), false) + require.NoError(t, err) + + // Simulate a different process taking the lock. + newOwner := lockValue{ + ProcessID: 9, + HostID: "stolen-host", + ExpiresAt: time.Now().Add(ttl).Unix(), + } + raw, marshalErr := newOwner.Marshal() + require.NoError(t, marshalErr) + updateErr := db.Model(&model.KV{}). + Where("key = ?", lockKey). + Update("value", raw).Error + require.NoError(t, updateErr) + + err = service.renew(context.Background()) + require.ErrorIs(t, err, ErrLockLost) + }) +} diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 767148b8..830be42b 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -7,14 +7,12 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/x509" "encoding/base64" "fmt" "log/slog" "path" "time" - "github.com/fxamacker/cbor/v2" "github.com/go-webauthn/webauthn/protocol" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwk" @@ -36,15 +34,17 @@ type TestService struct { appConfigService *AppConfigService ldapService *LdapService fileStorage storage.FileStorage + appLockService *AppLockService externalIdPKey jwk.Key } -func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, fileStorage storage.FileStorage) (*TestService, error) { +func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) { s := &TestService{ db: db, appConfigService: appConfigService, jwtService: jwtService, ldapService: ldapService, + appLockService: appLockService, fileStorage: fileStorage, } err := s.initExternalIdP() @@ -290,8 +290,8 @@ func (s *TestService) SeedDatabase(baseURL string) error { // openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \ // openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout) - publicKeyPasskey1, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==") - publicKeyPasskey2, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==") + publicKeyPasskey1, _ := base64.StdEncoding.DecodeString("pQMmIAEhWCDBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHCJYIIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmGAQI=") + publicKeyPasskey2, _ := base64.StdEncoding.DecodeString("pSJYIPmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouHAQIDJiABIVggj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbI=") webauthnCredentials := []model.WebauthnCredential{ { Name: "Passkey 1", @@ -320,6 +320,10 @@ func (s *TestService) SeedDatabase(baseURL string) error { Challenge: "challenge", ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)), UserVerification: "preferred", + CredentialParams: model.CredentialParameters{ + {Type: "public-key", Algorithm: -7}, + {Type: "public-key", Algorithm: -257}, + }, } if err := tx.Create(&webauthnSession).Error; err != nil { return err @@ -329,9 +333,10 @@ func (s *TestService) SeedDatabase(baseURL string) error { Base: model.Base{ ID: "5f1fa856-c164-4295-961e-175a0d22d725", }, - Name: "Test API Key", - Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20", - UserID: users[0].ID, + Name: "Test API Key", + Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20", + UserID: users[0].ID, + ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)), } if err := tx.Create(&apiKey).Error; err != nil { return err @@ -384,6 +389,20 @@ func (s *TestService) SeedDatabase(baseURL string) error { } } + keyValues := []model.KV{ + { + Key: jwkutils.PrivateKeyDBKey, + // {"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"} + Value: utils.Ptr("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"), + }, + } + + for _, kv := range keyValues { + if err := tx.Create(&kv).Error; err != nil { + return err + } + } + return nil }) @@ -469,47 +488,29 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error { return err } + // Manually set instance ID + err = s.appConfigService.UpdateAppConfigValues(ctx, "instanceId", "test-instance-id") + if err != nil { + return err + } + // Reload the app config from the database after resetting the values - return s.appConfigService.LoadDbConfig(ctx) + err = s.appConfigService.LoadDbConfig(ctx) + if err != nil { + return err + } + + // Reload the JWK + if err := s.jwtService.LoadOrGenerateKey(); err != nil { + return err + } + + return nil } -func (s *TestService) SetJWTKeys() { - const privateKeyString = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}` - - privateKey, _ := jwk.ParseKey([]byte(privateKeyString)) - _ = s.jwtService.SetKey(privateKey) -} - -// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key -func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) { - decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey) - if err != nil { - return nil, fmt.Errorf("failed to decode base64 key: %w", err) - } - pubKey, err := x509.ParsePKIXPublicKey(decodedKey) - if err != nil { - return nil, fmt.Errorf("failed to parse public key: %w", err) - } - - ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey) - if !ok { - return nil, fmt.Errorf("not an ECDSA public key") - } - - coseKey := map[int]interface{}{ - 1: 2, // Key type: EC2 - 3: -7, // Algorithm: ECDSA with SHA-256 - -1: 1, // Curve: P-256 - -2: ecdsaPubKey.X.Bytes(), // X coordinate - -3: ecdsaPubKey.Y.Bytes(), // Y coordinate - } - - cborPublicKey, err := cbor.Marshal(coseKey) - if err != nil { - return nil, fmt.Errorf("failed to marshal COSE key: %w", err) - } - - return cborPublicKey, nil +func (s *TestService) ResetLock(ctx context.Context) error { + _, err := s.appLockService.Acquire(ctx, true) + return err } // SyncLdap triggers an LDAP synchronization diff --git a/backend/internal/service/export_service.go b/backend/internal/service/export_service.go new file mode 100644 index 00000000..31e2ceae --- /dev/null +++ b/backend/internal/service/export_service.go @@ -0,0 +1,217 @@ +package service + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "io" + "path/filepath" + + "gorm.io/gorm" + + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/storage" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +// ExportService handles exporting Pocket ID data into a ZIP archive. +type ExportService struct { + db *gorm.DB + storage storage.FileStorage +} + +func NewExportService(db *gorm.DB, storage storage.FileStorage) *ExportService { + return &ExportService{ + db: db, + storage: storage, + } +} + +// ExportToZip performs the full export process and writes the ZIP data to the given writer. +func (s *ExportService) ExportToZip(ctx context.Context, w io.Writer) error { + dbData, err := s.extractDatabase() + if err != nil { + return err + } + + return s.writeExportZipStream(ctx, w, dbData) +} + +// extractDatabase reads all tables into a DatabaseExport struct +func (s *ExportService) extractDatabase() (DatabaseExport, error) { + schema, err := utils.LoadDBSchemaTypes(s.db) + if err != nil { + return DatabaseExport{}, fmt.Errorf("failed to load schema types: %w", err) + } + + version, err := s.schemaVersion() + if err != nil { + return DatabaseExport{}, err + } + + out := DatabaseExport{ + Provider: s.db.Name(), + Version: version, + Tables: map[string][]map[string]any{}, + // These tables need to be inserted in a specific order because of foreign key constraints + // Not all tables are listed here, because not all tables are order-dependent + TableOrder: []string{"users", "user_groups", "oidc_clients"}, + } + + for table := range schema { + if table == "storage" || table == "schema_migrations" { + continue + } + err = s.dumpTable(table, schema[table], &out) + if err != nil { + return DatabaseExport{}, err + } + } + + return out, nil +} + +func (s *ExportService) schemaVersion() (uint, error) { + var version uint + if err := s.db.Raw("SELECT version FROM schema_migrations").Row().Scan(&version); err != nil { + return 0, fmt.Errorf("failed to query schema version: %w", err) + } + return version, nil +} + +// dumpTable selects all rows from a table and appends them to out.Tables +func (s *ExportService) dumpTable(table string, types utils.DBSchemaTableTypes, out *DatabaseExport) error { + rows, err := s.db.Raw("SELECT * FROM " + table).Rows() + if err != nil { + return fmt.Errorf("failed to read table %s: %w", table, err) + } + defer rows.Close() + + cols, _ := rows.Columns() + if len(cols) != len(types) { + // Should never happen... + return fmt.Errorf("mismatched columns in table (%d) and schema (%d)", len(cols), len(types)) + } + + for rows.Next() { + vals := s.getScanValuesForTable(cols, types) + err = rows.Scan(vals...) + if err != nil { + return fmt.Errorf("failed to scan row in table %s: %w", table, err) + } + + rowMap := make(map[string]any, len(cols)) + for i, col := range cols { + rowMap[col] = vals[i] + } + + // Skip the app lock row in the kv table + if table == "kv" { + if keyPtr, ok := rowMap["key"].(*string); ok && keyPtr != nil && *keyPtr == lockKey { + continue + } + } + + out.Tables[table] = append(out.Tables[table], rowMap) + } + + return rows.Err() +} + +func (s *ExportService) getScanValuesForTable(cols []string, types utils.DBSchemaTableTypes) []any { + res := make([]any, len(cols)) + for i, col := range cols { + // Store a pointer + // Note: don't create a helper function for this switch, because it would return type "any" and mess everything up + // If the column is nullable, we need a pointer to a pointer! + switch types[col].Name { + case "boolean", "bool": + var x bool + if types[col].Nullable { + res[i] = utils.Ptr(utils.Ptr(x)) + } else { + res[i] = utils.Ptr(x) + } + case "blob", "bytea", "jsonb": + // Treat jsonb columns as binary too + var x []byte + if types[col].Nullable { + res[i] = utils.Ptr(utils.Ptr(x)) + } else { + res[i] = utils.Ptr(x) + } + case "timestamp", "timestamptz", "timestamp with time zone", "datetime": + var x datatype.DateTime + if types[col].Nullable { + res[i] = utils.Ptr(utils.Ptr(x)) + } else { + res[i] = utils.Ptr(x) + } + case "integer", "int", "bigint": + var x int64 + if types[col].Nullable { + res[i] = utils.Ptr(utils.Ptr(x)) + } else { + res[i] = utils.Ptr(x) + } + default: + // Treat everything else as a string (including the "numeric" type) + var x string + if types[col].Nullable { + res[i] = utils.Ptr(utils.Ptr(x)) + } else { + res[i] = utils.Ptr(x) + } + } + } + + return res +} + +func (s *ExportService) writeExportZipStream(ctx context.Context, w io.Writer, dbData DatabaseExport) error { + zipWriter := zip.NewWriter(w) + + // Add database.json + jsonWriter, err := zipWriter.Create("database.json") + if err != nil { + return fmt.Errorf("failed to create database.json in zip: %w", err) + } + + jsonEncoder := json.NewEncoder(jsonWriter) + jsonEncoder.SetEscapeHTML(false) + + if err := jsonEncoder.Encode(dbData); err != nil { + return fmt.Errorf("failed to encode database.json: %w", err) + } + + // Add uploaded files + if err := s.addUploadsToZip(ctx, zipWriter); err != nil { + return err + } + + return zipWriter.Close() +} + +// addUploadsToZip adds all files from the storage to the ZIP archive under the "uploads/" directory +func (s *ExportService) addUploadsToZip(ctx context.Context, zipWriter *zip.Writer) error { + return s.storage.Walk(ctx, "/", func(p storage.ObjectInfo) error { + zipPath := filepath.Join("uploads", p.Path) + + w, err := zipWriter.Create(zipPath) + if err != nil { + return fmt.Errorf("failed to create zip entry for %s: %w", zipPath, err) + } + + f, _, err := s.storage.Open(ctx, p.Path) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", zipPath, err) + } + defer f.Close() + + if _, err := io.Copy(w, f); err != nil { + return fmt.Errorf("failed to copy file %s into zip: %w", zipPath, err) + } + return nil + }) +} diff --git a/backend/internal/service/import_service.go b/backend/internal/service/import_service.go new file mode 100644 index 00000000..1c0ec7cf --- /dev/null +++ b/backend/internal/service/import_service.go @@ -0,0 +1,264 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + + "gorm.io/gorm" + + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/storage" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +// ImportService handles importing Pocket ID data from an exported ZIP archive. +type ImportService struct { + db *gorm.DB + storage storage.FileStorage +} + +type DatabaseExport struct { + Provider string `json:"provider"` + Version uint `json:"version"` + Tables map[string][]map[string]any `json:"tables"` + TableOrder []string `json:"tableOrder"` +} + +func NewImportService(db *gorm.DB, storage storage.FileStorage) *ImportService { + return &ImportService{ + db: db, + storage: storage, + } +} + +// ImportFromZip performs the full import process from the given ZIP reader. +func (s *ImportService) ImportFromZip(ctx context.Context, r *zip.Reader) error { + dbData, err := processZipDatabaseJson(r.File) + if err != nil { + return err + } + + err = s.ImportDatabase(dbData) + if err != nil { + return err + } + + err = s.importUploads(ctx, r.File) + if err != nil { + return err + } + + return nil +} + +// ImportDatabase only imports the database data from the given DatabaseExport struct. +func (s *ImportService) ImportDatabase(dbData DatabaseExport) error { + err := s.resetSchema(dbData.Version, dbData.Provider) + if err != nil { + return err + } + + err = s.insertData(dbData) + if err != nil { + return err + } + + return nil +} + +// processZipDatabaseJson extracts database.json from the ZIP archive +func processZipDatabaseJson(files []*zip.File) (dbData DatabaseExport, err error) { + for _, f := range files { + if f.Name == "database.json" { + return parseDatabaseJsonStream(f) + } + } + return dbData, errors.New("database.json not found in the ZIP file") +} + +func parseDatabaseJsonStream(f *zip.File) (dbData DatabaseExport, err error) { + rc, err := f.Open() + if err != nil { + return dbData, fmt.Errorf("failed to open database.json: %w", err) + } + defer rc.Close() + + err = json.NewDecoder(rc).Decode(&dbData) + if err != nil { + return dbData, fmt.Errorf("failed to decode database.json: %w", err) + } + + return dbData, nil +} + +// importUploads imports files from the uploads/ directory in the ZIP archive +func (s *ImportService) importUploads(ctx context.Context, files []*zip.File) error { + const maxFileSize = 50 << 20 // 50 MiB + const uploadsPrefix = "uploads/" + + for _, f := range files { + if !strings.HasPrefix(f.Name, uploadsPrefix) { + continue + } + + if f.UncompressedSize64 > maxFileSize { + return fmt.Errorf("file %s too large (%d bytes)", f.Name, f.UncompressedSize64) + } + + targetPath := strings.TrimPrefix(f.Name, uploadsPrefix) + if strings.HasSuffix(f.Name, "/") || targetPath == "" { + continue // Skip directories + } + + err := s.storage.DeleteAll(ctx, targetPath) + if err != nil { + return fmt.Errorf("failed to delete existing file %s: %w", targetPath, err) + } + + rc, err := f.Open() + if err != nil { + return err + } + + buf, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return fmt.Errorf("read file %s: %w", f.Name, err) + } + + err = s.storage.Save(ctx, targetPath, bytes.NewReader(buf)) + if err != nil { + return fmt.Errorf("failed to save file %s: %w", targetPath, err) + } + } + + return nil +} + +// resetSchema drops the existing schema and migrates to the target version +func (s *ImportService) resetSchema(targetVersion uint, exportDbProvider string) error { + sqlDb, err := s.db.DB() + if err != nil { + return fmt.Errorf("failed to get sql.DB: %w", err) + } + + m, err := utils.GetEmbeddedMigrateInstance(sqlDb) + if err != nil { + return fmt.Errorf("failed to get migrate instance: %w", err) + } + + err = m.Drop() + if err != nil { + return fmt.Errorf("failed to drop existing schema: %w", err) + } + + // Needs to be called again to re-create the schema_migrations table + m, err = utils.GetEmbeddedMigrateInstance(sqlDb) + if err != nil { + return fmt.Errorf("failed to get migrate instance: %w", err) + } + + err = m.Migrate(targetVersion) + if err != nil { + return fmt.Errorf("migration failed: %w", err) + } + + return nil +} + +// insertData populates the DB with the imported data +func (s *ImportService) insertData(dbData DatabaseExport) error { + schema, err := utils.LoadDBSchemaTypes(s.db) + if err != nil { + return fmt.Errorf("failed to load schema types: %w", err) + } + + return s.db.Transaction(func(tx *gorm.DB) error { + // Iterate through all tables + // Some tables need to be processed in order + tables := make([]string, 0, len(dbData.Tables)) + tables = append(tables, dbData.TableOrder...) + + for t := range dbData.Tables { + // Skip tables already present where the order matters + // Also skip the schema_migrations table + if slices.Contains(dbData.TableOrder, t) || t == "schema_migrations" { + continue + } + tables = append(tables, t) + } + + // Insert rows + for _, table := range tables { + for _, row := range dbData.Tables[table] { + err = normalizeRowWithSchema(row, table, schema) + if err != nil { + return fmt.Errorf("failed to normalize row for table '%s': %w", table, err) + } + err = tx.Table(table).Create(row).Error + if err != nil { + return fmt.Errorf("failed inserting into table '%s': %w", table, err) + } + } + } + return nil + }) +} + +// normalizeRowWithSchema converts row values based on the DB schema +func normalizeRowWithSchema(row map[string]any, table string, schema utils.DBSchemaTypes) error { + if schema[table] == nil { + return fmt.Errorf("schema not found for table '%s'", table) + } + + for col, val := range row { + if val == nil { + // If the value is nil, skip the column + continue + } + + colType := schema[table][col] + + switch colType.Name { + case "timestamp", "timestamptz", "timestamp with time zone", "datetime": + // Dates are stored as strings + str, ok := val.(string) + if !ok { + return fmt.Errorf("value for column '%s/%s' was expected to be a string, but was '%T'", table, col, val) + } + d, err := datatype.DateTimeFromString(str) + if err != nil { + return fmt.Errorf("failed to decode value for column '%s/%s' as timestamp: %w", table, col, err) + } + row[col] = d + + case "blob", "bytea", "jsonb": + // Binary data and jsonb data is stored in the file as base64-encoded string + str, ok := val.(string) + if !ok { + return fmt.Errorf("value for column '%s/%s' was expected to be a string, but was '%T'", table, col, val) + } + b, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return fmt.Errorf("failed to decode value for column '%s/%s' from base64: %w", table, col, err) + } + + // For jsonb, we additionally cast to json.RawMessage + if colType.Name == "jsonb" { + row[col] = json.RawMessage(b) + } else { + row[col] = b + } + } + } + + return nil +} diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go index eb9aef11..07203a99 100644 --- a/backend/internal/service/jwt_service.go +++ b/backend/internal/service/jwt_service.go @@ -48,6 +48,7 @@ const ( ) type JwtService struct { + db *gorm.DB envConfig *common.EnvConfigSchema privateKey jwk.Key keyId string @@ -58,7 +59,6 @@ type JwtService struct { func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) { service := &JwtService{} - // Ensure keys are generated or loaded err := service.init(db, appConfigService, &common.EnvConfig) if err != nil { return nil, err @@ -70,14 +70,15 @@ func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) { s.appConfigService = appConfigService s.envConfig = envConfig + s.db = db // Ensure keys are generated or loaded - return s.loadOrGenerateKey(db) + return s.LoadOrGenerateKey() } -func (s *JwtService) loadOrGenerateKey(db *gorm.DB) error { +func (s *JwtService) LoadOrGenerateKey() error { // Get the key provider - keyProvider, err := jwkutils.GetKeyProvider(db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value) + keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value) if err != nil { return fmt.Errorf("failed to get key provider: %w", err) } diff --git a/backend/internal/utils/db_migration_util.go b/backend/internal/utils/db_migration_util.go new file mode 100644 index 00000000..cbf33253 --- /dev/null +++ b/backend/internal/utils/db_migration_util.go @@ -0,0 +1,130 @@ +package utils + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database" + postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres" + sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/resources" +) + +// MigrateDatabase applies database migrations using embedded migration files or fetches them from GitHub if a downgrade is detected. +func MigrateDatabase(sqlDb *sql.DB) error { + m, err := GetEmbeddedMigrateInstance(sqlDb) + if err != nil { + return fmt.Errorf("failed to get migrate instance: %w", err) + } + + path := "migrations/" + string(common.EnvConfig.DbProvider) + requiredVersion, err := getRequiredMigrationVersion(path) + if err != nil { + return fmt.Errorf("failed to get last migration version: %w", err) + } + + currentVersion, _, _ := m.Version() + if currentVersion > requiredVersion { + slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion))) + if !common.EnvConfig.AllowDowngrade { + return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion) + } + slog.Info("Fetching migrations from GitHub to handle possible downgrades") + return migrateDatabaseFromGitHub(sqlDb, requiredVersion) + } + + if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("failed to apply embedded migrations: %w", err) + } + return nil +} + +// GetEmbeddedMigrateInstance creates a migrate.Migrate instance using embedded migration files. +func GetEmbeddedMigrateInstance(sqlDb *sql.DB) (*migrate.Migrate, error) { + path := "migrations/" + string(common.EnvConfig.DbProvider) + source, err := iofs.New(resources.FS, path) + if err != nil { + return nil, fmt.Errorf("failed to create embedded migration source: %w", err) + } + + driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider) + if err != nil { + return nil, fmt.Errorf("failed to create migration driver: %w", err) + } + + m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver) + if err != nil { + return nil, fmt.Errorf("failed to create migration instance: %w", err) + } + return m, nil +} + +// newMigrationDriver creates a database.Driver instance based on the given database provider. +func newMigrationDriver(sqlDb *sql.DB, dbProvider common.DbProvider) (driver database.Driver, err error) { + switch dbProvider { + case common.DbProviderSqlite: + driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{ + NoTxWrap: true, + }) + case common.DbProviderPostgres: + driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{}) + default: + // Should never happen at this point + return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider) + } + if err != nil { + return nil, fmt.Errorf("failed to create migration driver: %w", err) + } + + return driver, nil +} + +// migrateDatabaseFromGitHub applies database migrations fetched from GitHub to handle downgrades. +func migrateDatabaseFromGitHub(sqlDb *sql.DB, version uint) error { + srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider) + + driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider) + if err != nil { + return fmt.Errorf("failed to create migration driver: %w", err) + } + + m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver) + if err != nil { + return fmt.Errorf("failed to create GitHub migration instance: %w", err) + } + + if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("failed to apply GitHub migrations: %w", err) + } + return nil +} + +// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found. +func getRequiredMigrationVersion(path string) (uint, error) { + entries, err := resources.FS.ReadDir(path) + if err != nil { + return 0, fmt.Errorf("failed to read migration directory: %w", err) + } + + var maxVersion uint + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + var version uint + n, err := fmt.Sscanf(name, "%d_", &version) + if err == nil && n == 1 { + if version > maxVersion { + maxVersion = version + } + } + } + + return maxVersion, nil +} diff --git a/backend/internal/utils/db_util.go b/backend/internal/utils/db_util.go new file mode 100644 index 00000000..6e7141ec --- /dev/null +++ b/backend/internal/utils/db_util.go @@ -0,0 +1,116 @@ +package utils + +import ( + "fmt" + "strings" + + "gorm.io/gorm" +) + +// DBTableExists checks if a table exists in the database +func DBTableExists(db *gorm.DB, tableName string) (exists bool, err error) { + switch db.Name() { + case "postgres": + query := `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ? + )` + err = db.Raw(query, tableName).Scan(&exists).Error + if err != nil { + return false, err + } + case "sqlite": + query := `SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name=?` + err = db.Raw(query, tableName).Scan(&exists).Error + if err != nil { + return false, err + } + default: + return false, fmt.Errorf("unsupported database dialect: %s", db.Name()) + } + + return exists, nil +} + +type DBSchemaColumn struct { + Name string + Nullable bool +} +type DBSchemaTableTypes = map[string]DBSchemaColumn +type DBSchemaTypes = map[string]DBSchemaTableTypes + +// LoadDBSchemaTypes retrieves the column types for all tables in the DB +// Result is a map of "table --> column --> {name: column type name, nullable: boolean}" +func LoadDBSchemaTypes(db *gorm.DB) (result DBSchemaTypes, err error) { + result = make(DBSchemaTypes) + + switch db.Name() { + case "postgres": + var rows []struct { + TableName string + ColumnName string + DataType string + Nullable bool + } + err := db. + Raw(` + SELECT table_name, column_name, data_type, is_nullable = 'YES' AS nullable + FROM information_schema.columns + WHERE table_schema = 'public'; + `). + Scan(&rows). + Error + if err != nil { + return nil, err + } + for _, r := range rows { + t := strings.ToLower(r.DataType) + if result[r.TableName] == nil { + result[r.TableName] = make(map[string]DBSchemaColumn) + } + result[r.TableName][r.ColumnName] = DBSchemaColumn{ + Name: strings.ToLower(t), + Nullable: r.Nullable, + } + } + + case "sqlite": + var tables []string + err = db. + Raw(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';`). + Scan(&tables). + Error + if err != nil { + return nil, err + } + for _, table := range tables { + var cols []struct { + Name string + Type string + Notnull bool + } + err := db. + Raw(`PRAGMA table_info("` + table + `");`). + Scan(&cols). + Error + if err != nil { + return nil, err + } + for _, c := range cols { + if result[table] == nil { + result[table] = make(map[string]DBSchemaColumn) + } + result[table][c.Name] = DBSchemaColumn{ + Name: strings.ToLower(c.Type), + Nullable: !c.Notnull, + } + } + } + + default: + return nil, fmt.Errorf("unsupported database dialect: %s", db.Name()) + } + + return result, nil +} diff --git a/backend/internal/utils/servicerunner.go b/backend/internal/utils/servicerunner.go index a1267912..4ec99618 100644 --- a/backend/internal/utils/servicerunner.go +++ b/backend/internal/utils/servicerunner.go @@ -38,6 +38,7 @@ func (r *ServiceRunner) Run(ctx context.Context) error { // Ignore context canceled errors here as they generally indicate that the service is stopping if rErr != nil && !errors.Is(rErr, context.Canceled) { + cancel() errCh <- rErr return } diff --git a/backend/internal/utils/servicerunner_test.go b/backend/internal/utils/servicerunner_test.go index 271a4c4d..90ff7775 100644 --- a/backend/internal/utils/servicerunner_test.go +++ b/backend/internal/utils/servicerunner_test.go @@ -61,6 +61,26 @@ func TestServiceRunner_Run(t *testing.T) { require.ErrorIs(t, err, expectedErr) }) + t.Run("service error cancels others", func(t *testing.T) { + expectedErr := errors.New("boom") + errorService := func(ctx context.Context) error { + return expectedErr + } + waitingService := func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + } + + runner := NewServiceRunner(errorService, waitingService) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + err := runner.Run(ctx) + require.Error(t, err) + require.ErrorIs(t, err, expectedErr) + }) + t.Run("context canceled", func(t *testing.T) { // Create a service that waits until context is canceled waitingService := func(ctx context.Context) error { diff --git a/backend/resources/e2e-tests/database.json b/backend/resources/e2e-tests/database.json new file mode 120000 index 00000000..a5d7ad50 --- /dev/null +++ b/backend/resources/e2e-tests/database.json @@ -0,0 +1 @@ +../../../tests/database.json \ No newline at end of file diff --git a/backend/resources/files_test.go b/backend/resources/files_test.go new file mode 100644 index 00000000..122c1df8 --- /dev/null +++ b/backend/resources/files_test.go @@ -0,0 +1,72 @@ +package resources + +import ( + "embed" + "slices" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// This test is meant to enforce that for every new migration added, a file with the same migration number exists for all supported databases +// This is necessary to ensure import/export works correctly +// Note: if a migration is not needed for a database, ensure there's a file with an empty (no-op) migration (e.g. even just a comment) +func TestMigrationsMatchingVersions(t *testing.T) { + // We can ignore migrations with version below 20251115000000 + const ignoreBefore = 20251115000000 + + // Scan postgres migrations + postgresMigrations := scanMigrations(t, FS, "migrations/postgres", ignoreBefore) + + // Scan sqlite migrations + sqliteMigrations := scanMigrations(t, FS, "migrations/sqlite", ignoreBefore) + + // Sort both lists for consistent comparison + slices.Sort(postgresMigrations) + slices.Sort(sqliteMigrations) + + // Compare the lists + assert.Equal(t, postgresMigrations, sqliteMigrations, "Migration versions must match between Postgres and SQLite") +} + +// scanMigrations scans a directory for migration files and returns a list of versions +func scanMigrations(t *testing.T, fs embed.FS, dir string, ignoreBefore int64) []int64 { + t.Helper() + + entries, err := fs.ReadDir(dir) + require.NoErrorf(t, err, "Failed to read directory '%s'", dir) + + // Divide by 2 because of up and down files + versions := make([]int64, 0, len(entries)/2) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filename := entry.Name() + + // Only consider .up.sql files + if !strings.HasSuffix(filename, ".up.sql") { + continue + } + + // Extract version from filename (format: _.up.sql) + versionString, _, ok := strings.Cut(filename, "_") + require.Truef(t, ok, "Migration file has unexpected format: %s", filename) + + version, err := strconv.ParseInt(versionString, 10, 64) + require.NoErrorf(t, err, "Failed to parse version from filename '%s'", filename) + + // Exclude migrations with version below ignoreBefore + if version < ignoreBefore { + continue + } + + versions = append(versions, version) + } + + return versions +} diff --git a/backend/resources/migrations/postgres/20251117141000_export_normalization.down.sql b/backend/resources/migrations/postgres/20251117141000_export_normalization.down.sql new file mode 100644 index 00000000..d34341f0 --- /dev/null +++ b/backend/resources/migrations/postgres/20251117141000_export_normalization.down.sql @@ -0,0 +1 @@ +-- No-op in Postgres \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20251117141000_export_normalization.up.sql b/backend/resources/migrations/postgres/20251117141000_export_normalization.up.sql new file mode 100644 index 00000000..d34341f0 --- /dev/null +++ b/backend/resources/migrations/postgres/20251117141000_export_normalization.up.sql @@ -0,0 +1 @@ +-- No-op in Postgres \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20251117141000_export_normalization.down.sql b/backend/resources/migrations/sqlite/20251117141000_export_normalization.down.sql new file mode 100644 index 00000000..939b3456 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251117141000_export_normalization.down.sql @@ -0,0 +1,133 @@ +PRAGMA foreign_keys = OFF; + +BEGIN; + +CREATE TABLE users_old +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME, + username TEXT COLLATE NOCASE NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + first_name TEXT, + last_name TEXT NOT NULL, + display_name TEXT NOT NULL, + is_admin NUMERIC DEFAULT 0 NOT NULL, + ldap_id TEXT, + locale TEXT, + disabled NUMERIC DEFAULT 0 NOT NULL +); + +INSERT INTO users_old ( + id, + created_at, + username, + email, + first_name, + last_name, + display_name, + is_admin, + ldap_id, + locale, + disabled +) +SELECT + id, + created_at, + username, + email, + first_name, + last_name, + display_name, + CASE WHEN is_admin THEN 1 ELSE 0 END, + ldap_id, + locale, + CASE WHEN disabled THEN 1 ELSE 0 END +FROM users; + +DROP TABLE users; + +ALTER TABLE users_old RENAME TO users; + +CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id); + + + +CREATE TABLE webauthn_credentials_old +( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + name TEXT NOT NULL, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + attestation_type TEXT NOT NULL, + transport BLOB NOT NULL, + user_id TEXT REFERENCES users ON DELETE CASCADE, + backup_eligible NUMERIC DEFAULT 0 NOT NULL, + backup_state NUMERIC DEFAULT 0 NOT NULL +); + +INSERT INTO webauthn_credentials_old ( + id, + created_at, + name, + credential_id, + public_key, + attestation_type, + transport, + user_id, + backup_eligible, + backup_state +) +SELECT + id, + created_at, + name, + credential_id, + public_key, + attestation_type, + transport, + user_id, + CASE WHEN backup_eligible THEN 1 ELSE 0 END, + CASE WHEN backup_state THEN 1 ELSE 0 END +FROM webauthn_credentials; + +DROP TABLE webauthn_credentials; + +ALTER TABLE webauthn_credentials_old RENAME TO webauthn_credentials; + + + +CREATE TABLE webauthn_sessions_old +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME, + challenge TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + user_verification TEXT NOT NULL, + credential_params TEXT DEFAULT '[]' NOT NULL +); + +INSERT INTO webauthn_sessions_old ( + id, + created_at, + challenge, + expires_at, + user_verification, + credential_params +) +SELECT + id, + created_at, + challenge, + expires_at, + user_verification, + credential_params +FROM webauthn_sessions; + +DROP TABLE webauthn_sessions; + +ALTER TABLE webauthn_sessions_old RENAME TO webauthn_sessions; + +COMMIT; + +PRAGMA foreign_keys = ON; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20251117141000_export_normalization.up.sql b/backend/resources/migrations/sqlite/20251117141000_export_normalization.up.sql new file mode 100644 index 00000000..b280d370 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251117141000_export_normalization.up.sql @@ -0,0 +1,144 @@ +PRAGMA foreign_keys = OFF; + +BEGIN; + +-- 1. Create a new table with BOOLEAN columns +CREATE TABLE users_new +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME, + username TEXT COLLATE NOCASE NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + first_name TEXT, + last_name TEXT NOT NULL, + display_name TEXT NOT NULL, + is_admin BOOLEAN DEFAULT FALSE NOT NULL, + ldap_id TEXT, + locale TEXT, + disabled BOOLEAN DEFAULT FALSE NOT NULL +); + +-- 2. Copy all existing data, converting numeric bools to real booleans +INSERT INTO users_new ( + id, + created_at, + username, + email, + first_name, + last_name, + display_name, + is_admin, + ldap_id, + locale, + disabled +) +SELECT + id, + created_at, + username, + email, + first_name, + last_name, + display_name, + CASE WHEN is_admin != 0 THEN TRUE ELSE FALSE END, + ldap_id, + locale, + CASE WHEN disabled != 0 THEN TRUE ELSE FALSE END +FROM users; + +-- 3. Drop old table +DROP TABLE users; + +-- 4. Rename new table to original name +ALTER TABLE users_new RENAME TO users; + +-- 5. Recreate index +CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id); + +-- 6. Create temporary table with changed credential_id type to BLOB +CREATE TABLE webauthn_credentials_dg_tmp +( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + name TEXT NOT NULL, + credential_id BLOB NOT NULL UNIQUE, + public_key BLOB NOT NULL, + attestation_type TEXT NOT NULL, + transport BLOB NOT NULL, + user_id TEXT REFERENCES users ON DELETE CASCADE, + backup_eligible BOOLEAN DEFAULT FALSE NOT NULL, + backup_state BOOLEAN DEFAULT FALSE NOT NULL +); + +-- 7. Copy existing data into the temporary table +INSERT INTO webauthn_credentials_dg_tmp ( + id, + created_at, + name, + credential_id, + public_key, + attestation_type, + transport, + user_id, + backup_eligible, + backup_state +) +SELECT + id, + created_at, + name, + credential_id, + public_key, + attestation_type, + transport, + user_id, + backup_eligible, + backup_state +FROM webauthn_credentials; + +-- 8. Drop old table +DROP TABLE webauthn_credentials; + +-- 9. Rename temporary table to original name +ALTER TABLE webauthn_credentials_dg_tmp + RENAME TO webauthn_credentials; + +-- 10. Create temporary table with credential_params type changed to BLOB +CREATE TABLE webauthn_sessions_dg_tmp +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME, + challenge TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + user_verification TEXT NOT NULL, + credential_params BLOB DEFAULT '[]' NOT NULL +); + +-- 11. Copy existing data into the temporary sessions table +INSERT INTO webauthn_sessions_dg_tmp ( + id, + created_at, + challenge, + expires_at, + user_verification, + credential_params +) +SELECT + id, + created_at, + challenge, + expires_at, + user_verification, + credential_params +FROM webauthn_sessions; + +-- 12. Drop old table +DROP TABLE webauthn_sessions; + +-- 13. Rename temporary sessions table to original name +ALTER TABLE webauthn_sessions_dg_tmp + RENAME TO webauthn_sessions; + +COMMIT; + +PRAGMA foreign_keys = ON; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7962c304..f3bc7153 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,7 +57,7 @@ importers: version: 13.2.2 '@tailwindcss/vite': specifier: ^4.1.17 - version: 4.1.17(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 4.1.17(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) axios: specifier: ^1.13.2 version: 1.13.2 @@ -75,10 +75,10 @@ importers: version: 1.5.4 runed: specifier: ^0.37.0 - version: 0.37.0(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(zod@4.1.13) + version: 0.37.0(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(zod@4.1.13) sveltekit-superforms: specifier: ^2.28.1 - version: 2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3) + version: 2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -103,13 +103,13 @@ importers: version: 0.559.0(svelte@5.45.8) '@sveltejs/adapter-static': specifier: ^3.0.10 - version: 3.0.10(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))) + version: 3.0.10(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))) '@sveltejs/kit': specifier: ^2.49.2 - version: 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@sveltejs/vite-plugin-svelte': specifier: ^6.2.1 - version: 6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@types/eslint': specifier: ^9.6.1 version: 9.6.1 @@ -121,7 +121,7 @@ importers: version: 1.5.6 bits-ui: specifier: ^2.14.4 - version: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8) + version: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8) eslint: specifier: ^9.39.1 version: 9.39.1(jiti@2.6.1) @@ -133,7 +133,7 @@ importers: version: 3.13.1(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.8) formsnap: specifier: ^2.0.1 - version: 2.0.1(svelte@5.45.8)(sveltekit-superforms@2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3)) + version: 2.0.1(svelte@5.45.8)(sveltekit-superforms@2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3)) globals: specifier: ^16.5.0 version: 16.5.0 @@ -181,16 +181,23 @@ importers: version: 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.2.7 - version: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + version: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) tests: + dependencies: + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 devDependencies: '@playwright/test': specifier: ^1.57.0 version: 1.57.0 + '@types/adm-zip': + specifier: ^0.5.7 + version: 0.5.7 '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^22.18.12 + version: 22.19.1 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -203,14 +210,11 @@ importers: packages: - '@ark/regex@0.0.0': - resolution: {integrity: sha512-p4vsWnd/LRGOdGQglbwOguIVhPmCAf5UzquvnDoxqhhPWTP84wWgi1INea8MgJ4SnI2gp37f13oA4Waz9vwNYg==} + '@ark/schema@0.55.0': + resolution: {integrity: sha512-IlSIc0FmLKTDGr4I/FzNHauMn0MADA6bCjT1wauu4k6MyxhC1R9gz0olNpIRvK7lGGDwtc/VO0RUDNvVQW5WFg==} - '@ark/schema@0.50.0': - resolution: {integrity: sha512-hfmP82GltBZDadIOeR3argKNlYYyB2wyzHp0eeAqAOFBQguglMV/S7Ip2q007bRtKxIMLDqFY6tfPie1dtssaQ==} - - '@ark/util@0.50.0': - resolution: {integrity: sha512-tIkgIMVRpkfXRQIEf0G2CJryZVtHVrqcWHMDa5QKo0OEEBu0tHkRSIMm4Ln8cd8Bn9TPZtvc/kE2Gma8RESPSg==} + '@ark/util@0.55.0': + resolution: {integrity: sha512-aWFNK7aqSvqFtVsl1xmbTjGbg91uqtJV7Za76YGNEwIO4qLjMfyY8flmmbhooYMuqPCO2jyxu8hve943D+w3bA==} '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} @@ -253,11 +257,11 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@emnapi/runtime@1.6.0': - resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==} + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - '@esbuild/aix-ppc64@0.25.11': - resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -268,8 +272,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.11': - resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -280,8 +284,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.11': - resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -292,8 +296,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.11': - resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -304,8 +308,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.11': - resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -316,8 +320,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.11': - resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -328,8 +332,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.11': - resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -340,8 +344,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.11': - resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -352,8 +356,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.11': - resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -364,8 +368,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.11': - resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -376,8 +380,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.11': - resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -388,8 +392,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.11': - resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -400,8 +404,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.11': - resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -412,8 +416,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.11': - resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -424,8 +428,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.11': - resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -436,8 +440,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.11': - resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -448,8 +452,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.11': - resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -460,8 +464,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.11': - resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -472,8 +476,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.11': - resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -484,8 +488,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.11': - resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -496,8 +500,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.11': - resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -508,8 +512,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.11': - resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -520,8 +524,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.11': - resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -532,8 +536,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.11': - resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -544,8 +548,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.11': - resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -556,8 +560,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.11': - resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -609,11 +613,11 @@ packages: '@exodus/schemasafe@1.3.0': resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} - '@finom/zod-to-json-schema@3.24.11': - resolution: {integrity: sha512-fL656yBPiWebtfGItvtXLWrFNGlF1NcDFS0WdMQXMs9LluVg0CfT5E2oXYp0pidl0vVG53XkW55ysijNkU5/hA==} + '@finom/zod-to-json-schema@3.24.12': + resolution: {integrity: sha512-mf8CyoW+dFvsvROvHIXznrYWdmlxvBJGIeQpGJaD9iBn23kSSPiC7H0YIqgziMZJDFIzL4VEFCwpcUSHmoeNVw==} deprecated: 'Use https://www.npmjs.com/package/zod-v3-to-json-schema instead. See issue comment for details: https://github.com/StefanTerdell/zod-to-json-schema/issues/178#issuecomment-3533122539' peerDependencies: - zod: ^4.0.14 + zod: ^3.25 || ^4.0.14 '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -654,124 +658,135 @@ packages: resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.4': - resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==} + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.4': - resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.3': - resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.3': - resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.3': - resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.3': - resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.3': - resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.3': - resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.3': - resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': - resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.3': - resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.34.4': - resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.34.4': - resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.4': - resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - '@img/sharp-linux-s390x@0.34.4': - resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.34.4': - resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.4': - resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.4': - resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.34.4': - resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.4': - resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.4': - resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.4': - resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] @@ -814,9 +829,6 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.11': - resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -1195,8 +1207,8 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - '@sveltejs/acorn-typescript@1.0.6': - resolution: {integrity: sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==} + '@sveltejs/acorn-typescript@1.0.7': + resolution: {integrity: sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==} peerDependencies: acorn: ^8.9.0 @@ -1329,6 +1341,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@types/adm-zip@0.5.7': + resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -1344,8 +1359,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/node@22.19.1': + resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} '@types/node@24.10.2': resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} @@ -1361,8 +1376,8 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} - '@types/validator@13.15.3': - resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} '@typeschema/class-validator@0.3.0': resolution: {integrity: sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==} @@ -1461,6 +1476,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1494,8 +1513,11 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - arktype@2.1.23: - resolution: {integrity: sha512-tyxNWX6xJVMb2EPJJ3OjgQS1G/vIeQRrZuY4DeBNQmh8n7geS+czgbauQWB6Pr+RXiOO8ChEey44XdmxsqGmfQ==} + arkregex@0.0.3: + resolution: {integrity: sha512-bU21QJOJEFJK+BPNgv+5bVXkvRxyAvgnon75D92newgHxkBJTgiFwQxusyViYyJkETsddPlHyspshDQcCzmkNg==} + + arktype@2.1.27: + resolution: {integrity: sha512-enctOHxI4SULBv/TDtCVi5M8oLd4J5SVlPUblXDzSsOYQNMzmVbUosGBnJuZDKmFlN5Ie0/QVEuTE+Z5X1UhsQ==} array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} @@ -1552,8 +1574,8 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} - caniuse-lite@1.0.30001751: - resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} + caniuse-lite@1.0.30001757: + resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1570,8 +1592,8 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - class-validator@0.14.2: - resolution: {integrity: sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==} + class-validator@0.14.3: + resolution: {integrity: sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==} cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} @@ -1610,9 +1632,6 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} - commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - comment-json@4.4.1: resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} engines: {node: '>= 6'} @@ -1661,8 +1680,8 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - dayjs@1.11.18: - resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} debounce-fn@6.0.0: resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} @@ -1721,8 +1740,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.4.2: - resolution: {integrity: sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==} + devalue@5.5.0: + resolution: {integrity: sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==} dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -1806,8 +1825,8 @@ packages: peerDependencies: esbuild: '*' - esbuild@0.25.11: - resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} hasBin: true @@ -1889,8 +1908,8 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - exsolve@1.0.7: - resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} @@ -2059,8 +2078,8 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inline-style-parser@0.2.4: - resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} @@ -2173,8 +2192,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libphonenumber-js@1.12.24: - resolution: {integrity: sha512-l5IlyL9AONj4voSd7q9xkuQOL4u8Ty44puTic7J88CmdXkxfGsRfoVLXHCxppwehgpb/Chdb80FFehHqjN3ItQ==} + libphonenumber-js@1.12.29: + resolution: {integrity: sha512-P2aLrbeqHbmh8+9P35LXQfXOKc7XJ0ymUKl7tyeyQjdRNfzunXWxQXGc4yl3fUf28fqLRfPY+vIVvFXK7KEBTw==} lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} @@ -2307,9 +2326,9 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime-types@3.0.1: - resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} - engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} @@ -2446,8 +2465,8 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-scurry@2.0.0: - resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} pathe@2.0.3: @@ -2716,11 +2735,11 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - sharp@0.34.4: - resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} shebang-command@2.0.0: @@ -2803,8 +2822,8 @@ packages: stubborn-utils@1.0.2: resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} - style-to-object@1.0.11: - resolution: {integrity: sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==} + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} @@ -2904,11 +2923,6 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - terser@5.44.0: - resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} - engines: {node: '>=10'} - hasBin: true - tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} @@ -2969,8 +2983,8 @@ packages: resolution: {integrity: sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA==} engines: {node: '>=20'} - typebox@1.0.43: - resolution: {integrity: sha512-NALtTG+DfndC+48JURdOQ6y6CrCEZl9xLM+BMNwFduUIUJv6AqqVT3ZcCf+jQ9uEJlLAhLyds/gkfufkpBh+2g==} + typebox@1.0.56: + resolution: {integrity: sha512-KMd1DJnIRqLUzAicpFmGqgmt+/IePCEmT/Jtywyyyn0hK6+dupQnxm7OAIn/cL/vu22jKi1XvDjDhrpatZ46kA==} typescript-eslint@8.49.0: resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} @@ -2988,11 +3002,14 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - unplugin@2.3.10: - resolution: {integrity: sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} uri-js@4.4.1: @@ -3113,11 +3130,6 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} - yaml@2.8.1: - resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} - engines: {node: '>= 14.6'} - hasBin: true - yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -3145,17 +3157,12 @@ packages: snapshots: - '@ark/regex@0.0.0': + '@ark/schema@0.55.0': dependencies: - '@ark/util': 0.50.0 + '@ark/util': 0.55.0 optional: true - '@ark/schema@0.50.0': - dependencies: - '@ark/util': 0.50.0 - optional: true - - '@ark/util@0.50.0': + '@ark/util@0.55.0': optional: true '@babel/code-frame@7.27.1': @@ -3208,162 +3215,162 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@emnapi/runtime@1.6.0': + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.25.11': + '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/aix-ppc64@0.27.1': optional: true - '@esbuild/android-arm64@0.25.11': + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.1': optional: true - '@esbuild/android-arm@0.25.11': + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.1': optional: true - '@esbuild/android-x64@0.25.11': + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.1': optional: true - '@esbuild/darwin-arm64@0.25.11': + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.1': optional: true - '@esbuild/darwin-x64@0.25.11': + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.1': optional: true - '@esbuild/freebsd-arm64@0.25.11': + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.1': optional: true - '@esbuild/freebsd-x64@0.25.11': + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.1': optional: true - '@esbuild/linux-arm64@0.25.11': + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.1': optional: true - '@esbuild/linux-arm@0.25.11': + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.1': optional: true - '@esbuild/linux-ia32@0.25.11': + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.1': optional: true - '@esbuild/linux-loong64@0.25.11': + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.1': optional: true - '@esbuild/linux-mips64el@0.25.11': + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.1': optional: true - '@esbuild/linux-ppc64@0.25.11': + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.1': optional: true - '@esbuild/linux-riscv64@0.25.11': + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.1': optional: true - '@esbuild/linux-s390x@0.25.11': + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.1': optional: true - '@esbuild/linux-x64@0.25.11': + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.1': optional: true - '@esbuild/netbsd-arm64@0.25.11': + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.1': optional: true - '@esbuild/netbsd-x64@0.25.11': + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.1': optional: true - '@esbuild/openbsd-arm64@0.25.11': + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.1': optional: true - '@esbuild/openbsd-x64@0.25.11': + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.1': optional: true - '@esbuild/openharmony-arm64@0.25.11': + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.1': optional: true - '@esbuild/sunos-x64@0.25.11': + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.1': optional: true - '@esbuild/win32-arm64@0.25.11': + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.1': optional: true - '@esbuild/win32-ia32@0.25.11': + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.1': optional: true - '@esbuild/win32-x64@0.25.11': + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.1': @@ -3418,7 +3425,7 @@ snapshots: '@exodus/schemasafe@1.3.0': optional: true - '@finom/zod-to-json-schema@3.24.11(zod@4.1.13)': + '@finom/zod-to-json-schema@3.24.12(zod@4.1.13)': dependencies: zod: 4.1.13 optional: true @@ -3467,90 +3474,98 @@ snapshots: '@img/colour@1.0.0': optional: true - '@img/sharp-darwin-arm64@0.34.4': + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.3 + '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true - '@img/sharp-darwin-x64@0.34.4': + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.3 + '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.3': + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true - '@img/sharp-libvips-darwin-x64@1.2.3': + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm64@1.2.3': + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm@1.2.3': + '@img/sharp-libvips-linux-arm@1.2.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.3': + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true - '@img/sharp-libvips-linux-s390x@1.2.3': + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true - '@img/sharp-libvips-linux-x64@1.2.3': + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': + '@img/sharp-libvips-linux-x64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.3': + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true - '@img/sharp-linux-arm64@0.34.4': + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.3 + '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true - '@img/sharp-linux-arm@0.34.4': + '@img/sharp-linux-arm@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.3 + '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@img/sharp-linux-ppc64@0.34.4': + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.3 + '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true - '@img/sharp-linux-s390x@0.34.4': + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.3 + '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true - '@img/sharp-linux-x64@0.34.4': + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.3 + '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true - '@img/sharp-linuxmusl-arm64@0.34.4': + '@img/sharp-linux-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 + '@img/sharp-libvips-linux-x64': 1.2.4 optional: true - '@img/sharp-linuxmusl-x64@0.34.4': + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true - '@img/sharp-wasm32@0.34.4': + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.6.0 + '@emnapi/runtime': 1.7.1 optional: true - '@img/sharp-win32-arm64@0.34.4': + '@img/sharp-win32-arm64@0.34.5': optional: true - '@img/sharp-win32-ia32@0.34.4': + '@img/sharp-win32-ia32@0.34.5': optional: true - '@img/sharp-win32-x64@0.34.4': + '@img/sharp-win32-x64@0.34.5': optional: true '@inlang/paraglide-js@2.6.0': @@ -3560,7 +3575,7 @@ snapshots: commander: 11.1.0 consola: 3.4.0 json5: 2.2.3 - unplugin: 2.3.10 + unplugin: 2.3.11 urlpattern-polyfill: 10.1.0 transitivePeerDependencies: - babel-plugin-macros @@ -3611,12 +3626,6 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/source-map@0.3.11': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - optional: true - '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -3906,51 +3915,51 @@ snapshots: '@standard-schema/spec@1.0.0': {} - '@sveltejs/acorn-typescript@1.0.6(acorn@8.15.0)': + '@sveltejs/acorn-typescript@1.0.7(acorn@8.15.0)': dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))': dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) - '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + '@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@standard-schema/spec': 1.0.0 - '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@sveltejs/acorn-typescript': 1.0.7(acorn@8.15.0) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.7.2 - devalue: 5.4.2 + devalue: 5.5.0 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 mrmime: 2.0.1 sade: 1.8.1 - set-cookie-parser: 2.7.1 + set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.45.8 - vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) debug: 4.4.3 svelte: 5.45.8 - vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) debug: 4.4.3 deepmerge: 4.3.1 magic-string: 0.30.21 svelte: 5.45.8 - vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) - vitefu: 1.1.1(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitefu: 1.1.1(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) transitivePeerDependencies: - supports-color @@ -4023,12 +4032,16 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 - '@tailwindcss/vite@4.1.17(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))': + '@tailwindcss/vite@4.1.17(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.1.17 '@tailwindcss/oxide': 4.1.17 tailwindcss: 4.1.17 - vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + + '@types/adm-zip@0.5.7': + dependencies: + '@types/node': 22.19.1 '@types/cookie@0.6.0': {} @@ -4045,9 +4058,9 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@24.10.1': + '@types/node@22.19.1': dependencies: - undici-types: 7.16.0 + undici-types: 6.21.0 '@types/node@24.10.2': dependencies: @@ -4065,14 +4078,14 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/validator@13.15.3': + '@types/validator@13.15.10': optional: true - '@typeschema/class-validator@0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.2)': + '@typeschema/class-validator@0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.3)': dependencies: '@typeschema/core': 0.14.0(@types/json-schema@7.0.15) optionalDependencies: - class-validator: 0.14.2 + class-validator: 0.14.3 transitivePeerDependencies: - '@types/json-schema' optional: true @@ -4179,10 +4192,10 @@ snapshots: '@vinejs/vine@3.0.1': dependencies: '@poppinss/macroable': 1.1.0 - '@types/validator': 13.15.3 + '@types/validator': 13.15.10 '@vinejs/compiler': 3.0.0 camelcase: 8.0.0 - dayjs: 1.11.18 + dayjs: 1.11.19 dlv: 1.1.3 normalize-url: 8.1.0 validator: 13.15.23 @@ -4199,6 +4212,8 @@ snapshots: acorn@8.15.0: {} + adm-zip@0.5.16: {} + ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -4229,11 +4244,16 @@ snapshots: aria-query@5.3.2: {} - arktype@2.1.23: + arkregex@0.0.3: dependencies: - '@ark/regex': 0.0.0 - '@ark/schema': 0.50.0 - '@ark/util': 0.50.0 + '@ark/util': 0.55.0 + optional: true + + arktype@2.1.27: + dependencies: + '@ark/schema': 0.55.0 + '@ark/util': 0.55.0 + arkregex: 0.0.3 optional: true array-timsort@1.0.3: {} @@ -4259,15 +4279,15 @@ snapshots: base64id@2.0.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8): + bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8) + runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8) svelte: 5.45.8 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8) tabbable: 6.3.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -4296,7 +4316,7 @@ snapshots: camelcase@8.0.0: optional: true - caniuse-lite@1.0.30001751: {} + caniuse-lite@1.0.30001757: {} chalk@4.1.2: dependencies: @@ -4313,10 +4333,10 @@ snapshots: dependencies: consola: 3.4.2 - class-validator@0.14.2: + class-validator@0.14.3: dependencies: - '@types/validator': 13.15.3 - libphonenumber-js: 1.12.24 + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.29 validator: 13.15.23 optional: true @@ -4350,9 +4370,6 @@ snapshots: commander@13.1.0: {} - commander@2.20.3: - optional: true - comment-json@4.4.1: dependencies: array-timsort: 1.0.3 @@ -4400,7 +4417,7 @@ snapshots: date-fns@4.1.0: {} - dayjs@1.11.18: + dayjs@1.11.19: optional: true debounce-fn@6.0.0: @@ -4431,7 +4448,7 @@ snapshots: detect-libc@2.1.2: {} - devalue@5.4.2: {} + devalue@5.5.0: {} dijkstrajs@1.0.3: {} @@ -4527,34 +4544,34 @@ snapshots: tslib: 2.4.0 optional: true - esbuild@0.25.11: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.11 - '@esbuild/android-arm': 0.25.11 - '@esbuild/android-arm64': 0.25.11 - '@esbuild/android-x64': 0.25.11 - '@esbuild/darwin-arm64': 0.25.11 - '@esbuild/darwin-x64': 0.25.11 - '@esbuild/freebsd-arm64': 0.25.11 - '@esbuild/freebsd-x64': 0.25.11 - '@esbuild/linux-arm': 0.25.11 - '@esbuild/linux-arm64': 0.25.11 - '@esbuild/linux-ia32': 0.25.11 - '@esbuild/linux-loong64': 0.25.11 - '@esbuild/linux-mips64el': 0.25.11 - '@esbuild/linux-ppc64': 0.25.11 - '@esbuild/linux-riscv64': 0.25.11 - '@esbuild/linux-s390x': 0.25.11 - '@esbuild/linux-x64': 0.25.11 - '@esbuild/netbsd-arm64': 0.25.11 - '@esbuild/netbsd-x64': 0.25.11 - '@esbuild/openbsd-arm64': 0.25.11 - '@esbuild/openbsd-x64': 0.25.11 - '@esbuild/openharmony-arm64': 0.25.11 - '@esbuild/sunos-x64': 0.25.11 - '@esbuild/win32-arm64': 0.25.11 - '@esbuild/win32-ia32': 0.25.11 - '@esbuild/win32-x64': 0.25.11 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 esbuild@0.27.1: optionalDependencies: @@ -4685,7 +4702,7 @@ snapshots: esutils@2.0.3: {} - exsolve@1.0.7: {} + exsolve@1.0.8: {} fast-check@3.23.2: dependencies: @@ -4737,11 +4754,11 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - formsnap@2.0.1(svelte@5.45.8)(sveltekit-superforms@2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3)): + formsnap@2.0.1(svelte@5.45.8)(sveltekit-superforms@2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3)): dependencies: svelte: 5.45.8 svelte-toolbelt: 0.5.0(svelte@5.45.8) - sveltekit-superforms: 2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3) + sveltekit-superforms: 2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3) fsevents@2.3.2: optional: true @@ -4785,7 +4802,7 @@ snapshots: dependencies: minimatch: 10.1.1 minipass: 7.1.2 - path-scurry: 2.0.0 + path-scurry: 2.0.1 globals@14.0.0: {} @@ -4835,7 +4852,7 @@ snapshots: imurmurhash@0.1.4: {} - inline-style-parser@0.2.4: {} + inline-style-parser@0.2.7: {} is-extglob@2.1.1: {} @@ -4921,7 +4938,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libphonenumber-js@1.12.24: + libphonenumber-js@1.12.29: optional: true lightningcss-android-arm64@1.30.2: @@ -5019,7 +5036,7 @@ snapshots: dependencies: mime-db: 1.52.0 - mime-types@3.0.1: + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -5063,7 +5080,7 @@ snapshots: dependencies: '@next/env': 16.0.7 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001751 + caniuse-lite: 1.0.30001757 postcss: 8.4.31 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) @@ -5078,7 +5095,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.7 '@next/swc-win32-x64-msvc': 16.0.7 '@playwright/test': 1.57.0 - sharp: 0.34.4 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -5154,7 +5171,7 @@ snapshots: path-key@3.1.1: {} - path-scurry@2.0.0: + path-scurry@2.0.1: dependencies: lru-cache: 11.2.2 minipass: 7.1.2 @@ -5170,7 +5187,7 @@ snapshots: pkg-types@2.3.0: dependencies: confbox: 0.2.2 - exsolve: 1.0.7 + exsolve: 1.0.8 pathe: 2.0.3 playwright-core@1.57.0: {} @@ -5268,11 +5285,11 @@ snapshots: commander: 13.1.0 conf: 15.0.2 debounce: 2.2.0 - esbuild: 0.25.11 + esbuild: 0.25.12 glob: 13.0.0 jiti: 2.4.2 log-symbols: 7.0.1 - mime-types: 3.0.1 + mime-types: 3.0.2 normalize-path: 3.0.0 nypm: 0.6.0 ora: 8.2.0 @@ -5346,23 +5363,23 @@ snapshots: esm-env: 1.2.2 svelte: 5.45.8 - runed@0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8): + runed@0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.45.8 optionalDependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) - runed@0.37.0(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(zod@4.1.13): + runed@0.37.0(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(zod@4.1.13): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.45.8 optionalDependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) zod: 4.1.13 sade@1.8.1: @@ -5379,36 +5396,38 @@ snapshots: set-blocking@2.0.0: {} - set-cookie-parser@2.7.1: {} + set-cookie-parser@2.7.2: {} - sharp@0.34.4: + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 semver: 7.7.3 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.4 - '@img/sharp-darwin-x64': 0.34.4 - '@img/sharp-libvips-darwin-arm64': 1.2.3 - '@img/sharp-libvips-darwin-x64': 1.2.3 - '@img/sharp-libvips-linux-arm': 1.2.3 - '@img/sharp-libvips-linux-arm64': 1.2.3 - '@img/sharp-libvips-linux-ppc64': 1.2.3 - '@img/sharp-libvips-linux-s390x': 1.2.3 - '@img/sharp-libvips-linux-x64': 1.2.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 - '@img/sharp-libvips-linuxmusl-x64': 1.2.3 - '@img/sharp-linux-arm': 0.34.4 - '@img/sharp-linux-arm64': 0.34.4 - '@img/sharp-linux-ppc64': 0.34.4 - '@img/sharp-linux-s390x': 0.34.4 - '@img/sharp-linux-x64': 0.34.4 - '@img/sharp-linuxmusl-arm64': 0.34.4 - '@img/sharp-linuxmusl-x64': 0.34.4 - '@img/sharp-wasm32': 0.34.4 - '@img/sharp-win32-arm64': 0.34.4 - '@img/sharp-win32-ia32': 0.34.4 - '@img/sharp-win32-x64': 0.34.4 + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 optional: true shebang-command@2.0.0: @@ -5505,9 +5524,9 @@ snapshots: stubborn-utils@1.0.2: {} - style-to-object@1.0.11: + style-to-object@1.0.14: dependencies: - inline-style-parser: 0.2.4 + inline-style-parser: 0.2.7 styled-jsx@5.1.6(react@19.2.1): dependencies: @@ -5549,11 +5568,11 @@ snapshots: runed: 0.28.0(svelte@5.45.8) svelte: 5.45.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8) - style-to-object: 1.0.11 + runed: 0.35.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8) + style-to-object: 1.0.14 svelte: 5.45.8 transitivePeerDependencies: - '@sveltejs/kit' @@ -5561,27 +5580,27 @@ snapshots: svelte-toolbelt@0.5.0(svelte@5.45.8): dependencies: clsx: 2.1.1 - style-to-object: 1.0.11 + style-to-object: 1.0.14 svelte: 5.45.8 svelte-toolbelt@0.7.1(svelte@5.45.8): dependencies: clsx: 2.1.1 runed: 0.23.4(svelte@5.45.8) - style-to-object: 1.0.11 + style-to-object: 1.0.14 svelte: 5.45.8 svelte@5.45.8: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) + '@sveltejs/acorn-typescript': 1.0.7(acorn@8.15.0) '@types/estree': 1.0.8 acorn: 8.15.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.4.2 + devalue: 5.5.0 esm-env: 1.2.2 esrap: 2.2.1 is-reference: 3.0.3 @@ -5589,26 +5608,26 @@ snapshots: magic-string: 0.30.21 zimmerframe: 1.1.4 - sveltekit-superforms@2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3): + sveltekit-superforms@2.28.1(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(@types/json-schema@7.0.15)(esbuild@0.27.1)(svelte@5.45.8)(typescript@5.9.3): dependencies: - '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) - devalue: 5.4.2 + '@sveltejs/kit': 2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.45.8)(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + devalue: 5.5.0 memoize-weak: 1.0.2 svelte: 5.45.8 ts-deepmerge: 7.0.3 optionalDependencies: '@exodus/schemasafe': 1.3.0 - '@finom/zod-to-json-schema': 3.24.11(zod@4.1.13) + '@finom/zod-to-json-schema': 3.24.12(zod@4.1.13) '@gcornut/valibot-json-schema': 0.42.0(esbuild@0.27.1)(typescript@5.9.3) - '@typeschema/class-validator': 0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.2) + '@typeschema/class-validator': 0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.3) '@vinejs/vine': 3.0.1 - arktype: 2.1.23 - class-validator: 0.14.2 + arktype: 2.1.27 + class-validator: 0.14.3 effect: 3.18.4 joi: 17.13.3 json-schema-to-ts: 3.1.1 superstruct: 2.0.2 - typebox: 1.0.43 + typebox: 1.0.56 valibot: 1.2.0(typescript@5.9.3) yup: 1.7.1 zod: 4.1.13 @@ -5633,14 +5652,6 @@ snapshots: tapable@2.3.0: {} - terser@5.44.0: - dependencies: - '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 - commander: 2.20.3 - source-map-support: 0.5.21 - optional: true - tiny-case@1.0.3: optional: true @@ -5696,7 +5707,7 @@ snapshots: dependencies: tagged-tag: 1.0.0 - typebox@1.0.43: + typebox@1.0.56: optional: true typescript-eslint@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): @@ -5714,9 +5725,11 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} - unplugin@2.3.10: + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.15.0 @@ -5743,9 +5756,9 @@ snapshots: vary@1.1.2: {} - vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1): + vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: - esbuild: 0.25.11 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 @@ -5756,13 +5769,11 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 - terser: 5.44.0 tsx: 4.21.0 - yaml: 2.8.1 - vitefu@1.1.1(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)): + vitefu@1.1.1(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): optionalDependencies: - vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) webpack-virtual-modules@0.6.2: {} @@ -5788,9 +5799,6 @@ snapshots: yaml@1.10.2: {} - yaml@2.8.1: - optional: true - yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh index 429211be..80817b10 100755 --- a/scripts/docker/entrypoint.sh +++ b/scripts/docker/entrypoint.sh @@ -14,19 +14,16 @@ PGID=${PGID:-1000} # Check if the group with PGID exists; if not, create it if ! getent group pocket-id-group > /dev/null 2>&1; then - echo "Creating group $PGID..." addgroup -g "$PGID" pocket-id-group fi # Check if a user with PUID exists; if not, create it if ! id -u pocket-id > /dev/null 2>&1; then if ! getent passwd "$PUID" > /dev/null 2>&1; then - echo "Creating user $PUID..." - adduser -u "$PUID" -G pocket-id-group pocket-id > /dev/null 2>&1 + adduser -uD "$PUID" -G pocket-id-group pocket-id > /dev/null 2>&1 else # If a user with the PUID already exists, use that user existing_user=$(getent passwd "$PUID" | cut -d: -f1) - echo "Using existing user: $existing_user" fi fi diff --git a/tests/package.json b/tests/package.json index 4ac96d54..5276c2c3 100644 --- a/tests/package.json +++ b/tests/package.json @@ -8,9 +8,13 @@ }, "devDependencies": { "@playwright/test": "^1.57.0", - "@types/node": "^24.10.1", + "@types/adm-zip": "^0.5.7", + "@types/node": "^22.18.12", "dotenv": "^17.2.3", "jose": "^6.1.2", "prettier": "^3.7.0" + }, + "dependencies": { + "adm-zip": "^0.5.16" } } diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index 0b0cc460..5ac10f10 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -21,11 +21,15 @@ export default defineConfig({ trace: 'on-first-retry' }, projects: [ - { name: 'setup', testMatch: /.*\.setup\.ts/ }, + { name: 'cli', testMatch: /cli\.spec\.ts/ }, + { name: 'auth-setup', testMatch: /auth\.setup\.ts/ }, { - name: 'chromium', - use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' }, - dependencies: ['setup'] + name: 'browser-chrome', + use: { ...devices['Desktop Chrome'], storageState: '.tmp/auth/user.json' }, + testIgnore: /cli\.spec\.ts/, + dependencies: ['auth-setup'] } - ] + ], + globalSetup: './specs/fixtures/global.setup.ts', + globalTeardown: './specs/fixtures/global.teardown.ts' }); diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json new file mode 100644 index 00000000..1461420a --- /dev/null +++ b/tests/resources/export/database.json @@ -0,0 +1,312 @@ +{ + "provider": "sqlite", + "version": 20251117141000, + "tableOrder": ["users", "user_groups", "oidc_clients"], + "tables": { + "api_keys": [ + { + "created_at": "2025-11-25T12:39:02Z", + "description": null, + "expiration_email_sent": false, + "expires_at": "2025-12-25T12:39:02Z", + "id": "5f1fa856-c164-4295-961e-175a0d22d725", + "key": "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20", + "last_used_at": null, + "name": "Test API Key", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + } + ], + "app_config_variables": [ + { + "key": "instanceId", + "value": "test-instance-id" + } + ], + "kv": [ + { + "key": "jwt_private_key.json", + "value": "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" + } + ], + "oidc_authorization_codes": [ + { + "client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055", + "code": "auth-code", + "code_challenge": null, + "code_challenge_method_sha256": null, + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-25T13:39:02Z", + "id": "6bdd221e-d9f7-4e3d-92c0-4be125802ba2", + "nonce": "nonce", + "scope": "openid profile", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + }, + { + "client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1", + "code": "federated", + "code_challenge": null, + "code_challenge_method_sha256": null, + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-25T13:39:02Z", + "id": "37e914bd-ff2c-4653-8cd8-550f0213e430", + "nonce": "nonce", + "scope": "openid profile", + "user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036" + } + ], + "oidc_clients": [ + { + "callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvY2FsbGJhY2siXQ==", + "created_at": "2025-11-25T12:39:02Z", + "created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", + "credentials": "e30=", + "dark_image_type": null, + "id": "3654a746-35d4-4321-ac61-0bdcff2b4055", + "image_type": "png", + "is_public": false, + "launch_url": "https://nextcloud.local", + "logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=", + "name": "Nextcloud", + "pkce_enabled": false, + "requires_reauthentication": false, + "secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC" + }, + { + "callback_urls": "WyJodHRwOi8vaW1taWNoL2F1dGgvY2FsbGJhY2siXQ==", + "created_at": "2025-11-25T12:39:02Z", + "created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", + "credentials": "e30=", + "dark_image_type": null, + "id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018", + "image_type": null, + "is_public": false, + "launch_url": null, + "logout_callback_urls": "bnVsbA==", + "name": "Immich", + "pkce_enabled": false, + "requires_reauthentication": false, + "secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe" + }, + { + "callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvY2FsbGJhY2siXQ==", + "created_at": "2025-11-25T12:39:02Z", + "created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", + "credentials": "e30=", + "dark_image_type": null, + "id": "7c21a609-96b5-4011-9900-272b8d31a9d1", + "image_type": null, + "is_public": false, + "launch_url": null, + "logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=", + "name": "Tailscale", + "pkce_enabled": false, + "requires_reauthentication": false, + "secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a" + }, + { + "callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkL2F1dGgvY2FsbGJhY2siXQ==", + "created_at": "2025-11-25T12:39:02Z", + "created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", + "credentials": "eyJmZWRlcmF0ZWRJZGVudGl0aWVzIjpbeyJpc3N1ZXIiOiJodHRwczovL2V4dGVybmFsLWlkcC5sb2NhbCIsInN1YmplY3QiOiJjNDgyMzJmZi1mZjY1LTQ1ZWQtYWU5Ni03YWZhOGE5YjQ0M2IiLCJhdWRpZW5jZSI6ImFwaTovL1BvY2tldElEIiwiandrcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTQxMS9hcGkvZXh0ZXJuYWxpZHAvandrcy5qc29uIn1dfQ==", + "dark_image_type": null, + "id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b", + "image_type": null, + "is_public": false, + "launch_url": null, + "logout_callback_urls": "bnVsbA==", + "name": "Federated", + "pkce_enabled": false, + "requires_reauthentication": false, + "secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe" + } + ], + "oidc_clients_allowed_user_groups": [ + { + "oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018", + "user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211" + } + ], + "oidc_refresh_tokens": [ + { + "client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055", + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-26T12:39:02Z", + "id": "4928604e-e689-410c-9b25-5b9b6db9e46e", + "scope": "openid profile email", + "token": "fef6e2e37eb990f0bd7abd48a41d530c54b6a1f139b556e35e62475e6f4cb38d", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + } + ], + "one_time_access_tokens": [ + { + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-25T13:39:02Z", + "id": "bf877753-4ea4-4c9c-bbbd-e198bb201cb8", + "token": "HPe6k6uiDRRVuAQV", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + }, + { + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-25T12:39:01Z", + "id": "d3afae24-fe2d-4a98-abec-cf0b8525096a", + "token": "YCGDtftvsvYWiXd0", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + }, + { + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-25T13:39:02Z", + "id": "defd5164-9d9b-4228-bbce-708e33f49360", + "token": "one-time-token", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + } + ], + "signup_tokens": [ + { + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-26T12:39:02Z", + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "token": "VALID1234567890A", + "usage_count": 0, + "usage_limit": 1 + }, + { + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-12-02T12:39:02Z", + "id": "dc3c9c96-714e-48eb-926e-2d7c7858e6cf", + "token": "PARTIAL567890ABC", + "usage_count": 2, + "usage_limit": 5 + }, + { + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-24T12:39:02Z", + "id": "44de1863-ffa5-4db1-9507-4887cd7a1e3f", + "token": "EXPIRED34567890B", + "usage_count": 1, + "usage_limit": 3 + }, + { + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-26T12:39:02Z", + "id": "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b", + "token": "FULLYUSED567890C", + "usage_count": 1, + "usage_limit": 1 + } + ], + "user_authorized_oidc_clients": [ + { + "client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055", + "last_used_at": "2025-08-01T13:00:00Z", + "scope": "openid profile email", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + }, + { + "client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1", + "last_used_at": "2025-08-10T14:00:00Z", + "scope": "openid profile email", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + }, + { + "client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b", + "last_used_at": "2025-08-12T12:00:00Z", + "scope": "openid profile email", + "user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036" + } + ], + "user_groups": [ + { + "created_at": "2025-11-25T12:39:02Z", + "friendly_name": "Developers", + "id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368", + "ldap_id": null, + "name": "developers" + }, + { + "created_at": "2025-11-25T12:39:02Z", + "friendly_name": "Designers", + "id": "adab18bf-f89d-4087-9ee1-70ff15b48211", + "ldap_id": null, + "name": "designers" + } + ], + "user_groups_users": [ + { + "user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + }, + { + "user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368", + "user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036" + }, + { + "user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + } + ], + "users": [ + { + "created_at": "2025-11-25T12:39:02Z", + "disabled": false, + "display_name": "Tim Cook", + "email": "tim.cook@test.com", + "first_name": "Tim", + "id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", + "is_admin": true, + "last_name": "Cook", + "ldap_id": null, + "locale": null, + "username": "tim" + }, + { + "created_at": "2025-11-25T12:39:02Z", + "disabled": false, + "display_name": "Craig Federighi", + "email": "craig.federighi@test.com", + "first_name": "Craig", + "id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", + "is_admin": false, + "last_name": "Federighi", + "ldap_id": null, + "locale": null, + "username": "craig" + } + ], + "webauthn_credentials": [ + { + "attestation_type": "none", + "backup_eligible": false, + "backup_state": false, + "created_at": "2025-11-25T12:39:02Z", + "credential_id": "dGVzdC1jcmVkZW50aWFsLXRpbQ==", + "id": "fa7977f9-7cf8-40fa-abca-42b917b6e692", + "name": "Passkey 1", + "public_key": "pQMmIAEhWCDBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHCJYIIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmGAQI=", + "transport": "WyJpbnRlcm5hbCJd", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + }, + { + "attestation_type": "none", + "backup_eligible": false, + "backup_state": false, + "created_at": "2025-11-25T12:39:02Z", + "credential_id": "dGVzdC1jcmVkZW50aWFsLWNyYWln", + "id": "4bcc54ef-01d1-4970-be51-669ccd8c0198", + "name": "Passkey 2", + "public_key": "pSJYIPmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouHAQIDJiABIVggj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbI=", + "transport": "WyJpbnRlcm5hbCJd", + "user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036" + } + ], + "webauthn_sessions": [ + { + "challenge": "challenge", + "created_at": "2025-11-25T12:39:02Z", + "credential_params": "W3sidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi03fSx7InR5cGUiOiJwdWJsaWMta2V5IiwiYWxnIjotMjU3fV0=", + "expires_at": "2025-11-25T13:39:02Z", + "id": "267f6907-7bc8-4ea1-9d47-c42a172dc1c7", + "user_verification": "preferred" + } + ] + } +} diff --git a/tests/resources/export/uploads/application-images b/tests/resources/export/uploads/application-images new file mode 120000 index 00000000..4fa7a7ee --- /dev/null +++ b/tests/resources/export/uploads/application-images @@ -0,0 +1 @@ +../../../../backend/resources/images/ \ No newline at end of file diff --git a/tests/resources/export/uploads/profile-pictures/defaults/CF.png b/tests/resources/export/uploads/profile-pictures/defaults/CF.png new file mode 100644 index 0000000000000000000000000000000000000000..7c399f556ceadd976984a26b7c1952de76f37616 GIT binary patch literal 6189 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 F{{U3ix;_8^ literal 0 HcmV?d00001 diff --git a/tests/assets/cloud-logo.png b/tests/resources/images/cloud-logo.png similarity index 100% rename from tests/assets/cloud-logo.png rename to tests/resources/images/cloud-logo.png diff --git a/tests/assets/cloud-logo.svg b/tests/resources/images/cloud-logo.svg similarity index 100% rename from tests/assets/cloud-logo.svg rename to tests/resources/images/cloud-logo.svg diff --git a/tests/assets/clouds.jpg b/tests/resources/images/clouds.jpg similarity index 100% rename from tests/assets/clouds.jpg rename to tests/resources/images/clouds.jpg diff --git a/tests/assets/pingvin-share-logo.png b/tests/resources/images/pingvin-share-logo.png similarity index 100% rename from tests/assets/pingvin-share-logo.png rename to tests/resources/images/pingvin-share-logo.png diff --git a/tests/assets/w3-schools-favicon.ico b/tests/resources/images/w3-schools-favicon.ico similarity index 100% rename from tests/assets/w3-schools-favicon.ico rename to tests/resources/images/w3-schools-favicon.ico diff --git a/tests/setup/docker-compose-postgres.yml b/tests/setup/docker-compose-postgres.yml index 09539b75..2c534f3d 100644 --- a/tests/setup/docker-compose-postgres.yml +++ b/tests/setup/docker-compose-postgres.yml @@ -11,7 +11,7 @@ services: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=pocket-id healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] + test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 @@ -27,3 +27,6 @@ services: depends_on: postgres: condition: service_healthy + +volumes: + pocket-id-test-data: diff --git a/tests/setup/docker-compose-s3.yml b/tests/setup/docker-compose-s3.yml index 159c1c8a..1fdc511d 100644 --- a/tests/setup/docker-compose-s3.yml +++ b/tests/setup/docker-compose-s3.yml @@ -38,3 +38,6 @@ services: depends_on: create-bucket: condition: service_completed_successfully + +volumes: + pocket-id-test-data: diff --git a/tests/setup/docker-compose.yml b/tests/setup/docker-compose.yml index 8ac7d80f..d4e65c14 100644 --- a/tests/setup/docker-compose.yml +++ b/tests/setup/docker-compose.yml @@ -11,13 +11,18 @@ services: pocket-id: image: pocket-id:test ports: - - '1411:1411' + - "1411:1411" environment: APP_ENV: test ENCRYPTION_KEY: test-encryption-key FILE_BACKEND: ${FILE_BACKEND} + volumes: + - pocket-id-test-data:/app/data build: args: - BUILD_TAGS=e2etest context: ../.. dockerfile: docker/Dockerfile + +volumes: + pocket-id-test-data: diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts index 816e3f0a..998006d0 100644 --- a/tests/specs/application-configuration.spec.ts +++ b/tests/specs/application-configuration.spec.ts @@ -122,12 +122,12 @@ test.describe('Update application images', () => { }); test('should upload images', async ({ page }) => { - await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico'); - await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png'); - await page.getByLabel('Dark Mode Logo').setInputFiles('assets/cloud-logo.png'); - await page.getByLabel('Email Logo').setInputFiles('assets/pingvin-share-logo.png'); - await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png'); - await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg'); + await page.getByLabel('Favicon').setInputFiles('resources/images/w3-schools-favicon.ico'); + await page.getByLabel('Light Mode Logo').setInputFiles('resources/images/pingvin-share-logo.png'); + await page.getByLabel('Dark Mode Logo').setInputFiles('resources/images/cloud-logo.png'); + await page.getByLabel('Email Logo').setInputFiles('resources/images/pingvin-share-logo.png'); + await page.getByLabel('Default Profile Picture').setInputFiles('resources/images/pingvin-share-logo.png'); + await page.getByLabel('Background Image').setInputFiles('resources/images/clouds.jpg'); await page.getByRole('button', { name: 'Save' }).last().click(); await expect(page.locator('[data-type="success"]')).toHaveText( @@ -154,7 +154,7 @@ test.describe('Update application images', () => { test('should only allow png/jpeg for email logo', async ({ page }) => { const emailLogoInput = page.getByLabel('Email Logo'); - await emailLogoInput.setInputFiles('assets/cloud-logo.svg'); + await emailLogoInput.setInputFiles('resources/images/cloud-logo.svg'); await page.getByRole('button', { name: 'Save' }).last().click(); await expect(page.locator('[data-type="error"]')).toHaveText( diff --git a/tests/specs/cli.spec.ts b/tests/specs/cli.spec.ts new file mode 100644 index 00000000..b7c8e1b9 --- /dev/null +++ b/tests/specs/cli.spec.ts @@ -0,0 +1,366 @@ +import { expect, test } from '@playwright/test'; +import AdmZip from 'adm-zip'; +import { execFileSync, ExecFileSyncOptions } from 'child_process'; +import crypto from 'crypto'; +import { users } from 'data'; +import fs from 'fs'; +import path from 'path'; +import { cleanupBackend } from 'utils/cleanup.util'; +import { pathFromRoot, tmpDir } from 'utils/fs.util'; + +const containerName = 'pocket-id'; +const setupDir = pathFromRoot('setup'); +const exampleExportPath = pathFromRoot('resources/export'); +const dockerCommandMaxBuffer = 100 * 1024 * 1024; +let mode: 'sqlite' | 'postgres' | 's3' = 'sqlite'; + +test.beforeAll(() => { + const dockerComposeLs = runDockerCommand(['compose', 'ls', '--format', 'json']); + if (dockerComposeLs.includes('postgres')) { + mode = 'postgres'; + } else if (dockerComposeLs.includes('s3')) { + mode = 's3'; + } + console.log(`Running CLI tests in ${mode.toUpperCase()} mode`); +}); + +test('Export', async ({ baseURL }) => { + // Reset the backend but with LDAP setup because the example export has no LDAP data + await cleanupBackend({ skipLdapSetup: true }); + + // Fetch the profile pictures because they get generated on demand + await Promise.all([ + fetch(`${baseURL}/api/users/${users.craig.id}/profile-picture.png`), + fetch(`${baseURL}/api/users/${users.tim.id}/profile-picture.png`) + ]); + + // Export the data from the seeded container + const exportPath = path.join(tmpDir, 'export.zip'); + const extractPath = path.join(tmpDir, 'export-extracted'); + + runExport(exportPath); + unzipExport(exportPath, extractPath); + + compareExports(exampleExportPath, extractPath); +}); + +test('Export via stdout', async ({ baseURL }) => { + await cleanupBackend({ skipLdapSetup: true }); + + await Promise.all([ + fetch(`${baseURL}/api/users/${users.craig.id}/profile-picture.png`), + fetch(`${baseURL}/api/users/${users.tim.id}/profile-picture.png`) + ]); + + const stdoutBuffer = runExportToStdout(); + const stdoutExtractPath = path.join(tmpDir, 'export-stdout-extracted'); + unzipExportBuffer(stdoutBuffer, stdoutExtractPath); + + compareExports(exampleExportPath, stdoutExtractPath); +}); + +test('Import', async () => { + // Reset the backend without seeding + await cleanupBackend({ skipSeed: true }); + + // Run the import with the example export data + const exampleExportArchivePath = path.join(tmpDir, 'example-export.zip'); + archiveExampleExport(exampleExportArchivePath); + + try { + runDockerComposeCommand(['stop', containerName]); + runImport(exampleExportArchivePath); + } finally { + runDockerComposeCommand(['up', '-d', containerName]); + } + + // Export again from the imported instance + const exportPath = path.join(tmpDir, 'export.zip'); + const exportExtracted = path.join(tmpDir, 'export-extracted'); + runExport(exportPath); + unzipExport(exportPath, exportExtracted); + + compareExports(exampleExportPath, exportExtracted); +}); + +test('Import via stdin', async () => { + await cleanupBackend({ skipSeed: true }); + + const exampleExportArchivePath = path.join(tmpDir, 'example-export-stdin.zip'); + const exampleExportBuffer = archiveExampleExport(exampleExportArchivePath); + + try { + runDockerComposeCommand(['stop', containerName]); + runImportFromStdin(exampleExportBuffer); + } finally { + runDockerComposeCommand(['up', '-d', containerName]); + } + + const exportPath = path.join(tmpDir, 'export-from-stdin.zip'); + const exportExtracted = path.join(tmpDir, 'export-from-stdin-extracted'); + runExport(exportPath); + unzipExport(exportPath, exportExtracted); + + compareExports(exampleExportPath, exportExtracted); +}); + +function compareExports(dir1: string, dir2: string): void { + const hashes1 = hashAllFiles(dir1); + const hashes2 = hashAllFiles(dir2); + + const files1 = Object.keys(hashes1).sort(); + const files2 = Object.keys(hashes2).sort(); + expect(files2).toEqual(files1); + + for (const file of files1) { + expect(hashes2[file], `${file} hash should match`).toEqual(hashes1[file]); + } + + // Compare database.json contents + const expectedData = loadJSON(path.join(dir1, 'database.json')); + const actualData = loadJSON(path.join(dir2, 'database.json')); + + // Check special fields + validateSpecialFields(actualData); + + // Normalize and compare + const normalizedExpected = normalizeJSON(expectedData); + const normalizedActual = normalizeJSON(actualData); + expect(normalizedActual).toEqual(normalizedExpected); +} + +function archiveExampleExport(outputPath: string): Buffer { + fs.rmSync(outputPath, { force: true }); + + const zip = new AdmZip(); + const files = fs.readdirSync(exampleExportPath); + for (const file of files) { + const filePath = path.join(exampleExportPath, file); + if (fs.statSync(filePath).isFile()) { + zip.addLocalFile(filePath); + } else if (fs.statSync(filePath).isDirectory()) { + zip.addLocalFolder(filePath, file); + } + } + + const buffer = zip.toBuffer(); + fs.writeFileSync(outputPath, buffer); + return buffer; +} + +// Helper to load JSON files +function loadJSON(path: string) { + return JSON.parse(fs.readFileSync(path, 'utf-8')); +} + +function normalizeJSON(obj: any): any { + if (typeof obj === 'string') { + try { + // Normalize JSON strings + const parsed = JSON.parse(atob(obj)); + return JSON.stringify(normalizeJSON(parsed)); + } catch { + return obj; + } + } + + if (Array.isArray(obj)) { + // Sort arrays to make order irrelevant + return obj + .map(normalizeJSON) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + } else if (obj && typeof obj === 'object') { + const ignoredKeys = ['id', 'created_at', 'expires_at', 'credentials', 'provider', 'version']; + + // Sort and normalize object keys, skipping ignored ones + return Object.keys(obj) + .filter((key) => !ignoredKeys.includes(key)) + .sort() + .reduce( + (acc, key) => { + acc[key] = normalizeJSON(obj[key]); + return acc; + }, + {} as Record + ); + } + + return obj; +} + +function validateSpecialFields(obj: any): void { + if (Array.isArray(obj)) { + for (const item of obj) validateSpecialFields(item); + } else if (obj && typeof obj === 'object') { + for (const [key, value] of Object.entries(obj)) { + if (key === 'id') { + expect(isUUID(value), `Expected '${value}' to be a valid UUID`).toBe(true); + } else if (key === 'created_at' || key === 'expires_at') { + expect( + isValidISODate(value), + `Expected '${key}' = ${value} to be a valid ISO 8601 date string` + ).toBe(true); + } else if (key === 'provider') { + expect( + ['postgres', 'sqlite'].includes(value as string), + `Expected 'provider' to be either 'postgres' or 'sqlite', got '${value}'` + ).toBe(true); + } else if (key === 'version') { + expect(value).toBeGreaterThanOrEqual(20251001000000); + } else { + validateSpecialFields(value); + } + } + } +} + +function isUUID(value: any): boolean { + if (typeof value !== 'string') return false; + const uuidRegex = /^[^-]{8}-[^-]{4}-[^-]{4}-[^-]{4}-[^-]{12}$/; + return uuidRegex.test(value); +} + +function isValidISODate(value: any): boolean { + const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; + if (!isoRegex.test(value)) return false; + const date = new Date(value); + return !isNaN(date.getTime()); +} + +function runImport(pathToFile: string) { + const importContainerId = runDockerComposeCommand([ + 'run', + '-d', + '-v', + `${pathToFile}:/app/data/pocket-id-export.zip`, + containerName, + '/app/pocket-id', + 'import', + '--path', + '/app/data/pocket-id-export.zip', + '--yes' + ]); + try { + runDockerCommand(['wait', importContainerId]); + } finally { + runDockerCommand(['rm', '-f', importContainerId]); + } +} + +function runImportFromStdin(archive: Buffer): void { + runDockerComposeCommandRaw( + ['run', '--rm', '-T', containerName, '/app/pocket-id', 'import', '--yes', '--path', '-'], + { input: archive } + ); +} + +function runExport(outputFile: string): void { + const containerId = runDockerComposeCommand([ + 'run', + '-d', + containerName, + '/app/pocket-id', + 'export', + '--path', + '/app/data/pocket-id-export.zip' + ]); + + try { + // Wait until export finishes + runDockerCommand(['wait', containerId]); + runDockerCommand(['cp', `${containerId}:/app/data/pocket-id-export.zip`, outputFile]); + } finally { + runDockerCommand(['rm', '-f', containerId]); + } + + expect(fs.existsSync(outputFile)).toBe(true); +} + +function runExportToStdout(): Buffer { + const res = runDockerComposeCommandRaw([ + 'run', + '--rm', + '-T', + containerName, + '/app/pocket-id', + 'export', + '--path', + '-' + ]); + fs.writeFileSync('export-stdout.txt', res); + return res; +} + +function unzipExport(zipFile: string, destDir: string): void { + fs.rmSync(destDir, { recursive: true, force: true }); + const zip = new AdmZip(zipFile); + zip.extractAllTo(destDir, true); +} + +function unzipExportBuffer(zipBuffer: Buffer, destDir: string): void { + fs.rmSync(destDir, { recursive: true, force: true }); + const zip = new AdmZip(zipBuffer); + zip.extractAllTo(destDir, true); +} + +function hashFile(filePath: string): string { + const buffer = fs.readFileSync(filePath); + return crypto.createHash('sha256').update(buffer).digest('hex'); +} + +function getAllFiles(dir: string, root = dir): string[] { + return fs.readdirSync(dir).flatMap((entry) => { + if (['.DS_Store', 'database.json'].includes(entry)) return []; + + const fullPath = path.join(dir, entry); + const stat = fs.statSync(fullPath); + return stat.isDirectory() ? getAllFiles(fullPath, root) : [path.relative(root, fullPath)]; + }); +} + +function hashAllFiles(dir: string): Record { + const files = getAllFiles(dir); + const hashes: Record = {}; + for (const relativePath of files) { + const fullPath = path.join(dir, relativePath); + hashes[relativePath] = hashFile(fullPath); + } + return hashes; +} + +function runDockerCommand(args: string[], options?: ExecFileSyncOptions): string { + return execFileSync('docker', args, { + cwd: setupDir, + stdio: 'pipe', + maxBuffer: dockerCommandMaxBuffer, + ...options + }) + .toString() + .trim(); +} + +function runDockerComposeCommand(args: string[]): string { + return runDockerComposeCommandRaw(args).toString().trim(); +} + +function runDockerComposeCommandRaw(args: string[], options?: ExecFileSyncOptions): Buffer { + return execFileSync('docker', dockerComposeArgs(args), { + cwd: setupDir, + stdio: 'pipe', + maxBuffer: dockerCommandMaxBuffer, + ...options + }) as Buffer; +} + +function dockerComposeArgs(args: string[]): string[] { + let dockerComposeFile = 'docker-compose.yml'; + switch (mode) { + case 'postgres': + dockerComposeFile = 'docker-compose-postgres.yml'; + break; + case 's3': + dockerComposeFile = 'docker-compose-s3.yml'; + break; + } + return ['compose', '-f', dockerComposeFile, ...args]; +} diff --git a/tests/specs/auth.setup.ts b/tests/specs/fixtures/auth.setup.ts similarity index 56% rename from tests/specs/auth.setup.ts rename to tests/specs/fixtures/auth.setup.ts index 4e0e7cbc..872f8ef1 100644 --- a/tests/specs/auth.setup.ts +++ b/tests/specs/fixtures/auth.setup.ts @@ -1,8 +1,9 @@ import { test as setup } from '@playwright/test'; -import authUtil from '../utils/auth.util'; -import { cleanupBackend } from '../utils/cleanup.util'; +import { pathFromRoot } from 'utils/fs.util'; +import authUtil from '../../utils/auth.util'; +import { cleanupBackend } from '../../utils/cleanup.util'; -const authFile = './.auth/user.json'; +const authFile = pathFromRoot('.tmp/auth/user.json'); setup('authenticate', async ({ page }) => { await cleanupBackend(); diff --git a/tests/specs/fixtures/global.setup.ts b/tests/specs/fixtures/global.setup.ts new file mode 100644 index 00000000..d30850d1 --- /dev/null +++ b/tests/specs/fixtures/global.setup.ts @@ -0,0 +1,8 @@ +import fs from 'fs'; +import { tmpDir } from 'utils/fs.util'; + +async function globalSetup() { + await fs.promises.mkdir(tmpDir, { recursive: true }); +} + +export default globalSetup; diff --git a/tests/specs/fixtures/global.teardown.ts b/tests/specs/fixtures/global.teardown.ts new file mode 100644 index 00000000..8991c453 --- /dev/null +++ b/tests/specs/fixtures/global.teardown.ts @@ -0,0 +1,8 @@ +import fs from 'fs'; +import { tmpDir } from 'utils/fs.util'; + +async function globalTeardown() { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); +} + +export default globalTeardown; diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index 5c25c0e2..e4c198d2 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -20,9 +20,9 @@ test.describe('Create OIDC client', () => { await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl); await page.locator('[role="tab"][data-value="light-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-light', 'assets/pingvin-share-logo.png'); + await page.setInputFiles('#oidc-client-logo-light', 'resources/images/pingvin-share-logo.png'); await page.locator('[role="tab"][data-value="dark-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-dark', 'assets/pingvin-share-logo.png'); + await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/pingvin-share-logo.png'); if (clientId) { await page.getByRole('button', { name: 'Show Advanced Options' }).click(); @@ -71,9 +71,9 @@ test('Edit OIDC client', async ({ page }) => { await page.getByLabel('Name').fill('Nextcloud updated'); await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback'); await page.locator('[role="tab"][data-value="light-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-light', 'assets/cloud-logo.png'); + await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png'); await page.locator('[role="tab"][data-value="dark-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-dark', 'assets/cloud-logo.png'); + await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png'); await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL); await page.getByRole('button', { name: 'Save' }).click(); diff --git a/tests/specs/user-signup.spec.ts b/tests/specs/user-signup.spec.ts index 6454bf0d..8cb48b30 100644 --- a/tests/specs/user-signup.spec.ts +++ b/tests/specs/user-signup.spec.ts @@ -70,7 +70,7 @@ test.describe('Initial User Signup', () => { }); test('Initial Signup - success flow', async ({ page }) => { - await cleanupBackend(true); + await cleanupBackend({ skipSeed: true }); await page.goto('/setup'); await page.getByLabel('First name').fill('Jane'); await page.getByLabel('Last name').fill('Smith'); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 0b392b76..168d7167 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { "baseUrl": ".", - "lib": ["ES2022"] + "lib": ["ES2022"], + "esModuleInterop": true, + "module": "es2022", + "moduleResolution": "node", + "target": "es2022" } } diff --git a/tests/utils/cleanup.util.ts b/tests/utils/cleanup.util.ts index 3e82b0b7..b0cc1f28 100644 --- a/tests/utils/cleanup.util.ts +++ b/tests/utils/cleanup.util.ts @@ -1,9 +1,9 @@ import playwrightConfig from '../playwright.config'; -export async function cleanupBackend(skipSeed = false) { +export async function cleanupBackend({ skipSeed = false, skipLdapSetup = false } = {}) { const url = new URL('/api/test/reset', playwrightConfig.use!.baseURL); - if (process.env.SKIP_LDAP_TESTS === 'true' || skipSeed) { + if (process.env.SKIP_LDAP_TESTS === 'true' || skipSeed || skipLdapSetup) { url.searchParams.append('skip-ldap', 'true'); } diff --git a/tests/utils/fs.util.ts b/tests/utils/fs.util.ts new file mode 100644 index 00000000..a5fc56cf --- /dev/null +++ b/tests/utils/fs.util.ts @@ -0,0 +1,7 @@ +import path from 'path'; + +export const tmpDir = pathFromRoot('.tmp'); + +export function pathFromRoot(p: string): string { + return path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', p); +}