From 2346a426a25c18769faaf0f07de1ca3892822ce4 Mon Sep 17 00:00:00 2001 From: icecheng Date: Thu, 30 Oct 2025 11:26:05 +0800 Subject: [PATCH] init --- .env.example | 16 - .freeleaps/devops/api-server.Dockerfile | 32 - .freeleaps/devops/helm-pkg/Chart.yaml | 8 +- .../devops/helm-pkg/templates/_helpers.tpl | 62 - .../devops/helm-pkg/templates/api-server.yaml | 58 - .../authentication/authentication-config.yaml | 21 + .../templates/authentication/certificate.yaml | 27 + .../templates/authentication/dashboard.yaml | 865 +++ .../templates/authentication/deployment.yaml | 131 + .../authentication/freeleapssecret.yaml | 20 + .../templates/authentication/ingress.yaml | 36 + .../authentication/opentelemetry-rbac.yaml | 46 + .../authentication/opentelemetry.yaml | 119 + .../templates/authentication/service.yaml | 26 + .../authentication/servicemonitor.yaml | 40 + .../templates/authentication/vpa.yaml | 32 + .../helm-pkg/templates/serviceaccount.yaml | 8 - .../devops/helm-pkg/templates/web-server.yaml | 58 - .freeleaps/devops/helm-pkg/values.alpha.yaml | 229 +- .freeleaps/devops/helm-pkg/values.prod.yaml | 220 +- .freeleaps/devops/helm-pkg/values.yaml | 186 +- .freeleaps/devops/nginx/default.conf | 44 - .freeleaps/devops/nginx/docker-entrypoint.sh | 11 - .freeleaps/devops/web-server.Dockerfile | 24 - .freeleaps/project.alpha.yaml | 29 +- .freeleaps/project.prod.yaml | 31 +- .gitignore | 57 +- Dockerfile | 39 + README.md | 68 - app/modules/sys/__init__.py => __init__.py | 0 app/modules/__init__.py | 20 - app/modules/sys/routes.py | 41 - app/routes.py | 25 - app/schema.py | 8 - app/setup_app.py | 49 - app/utils/config.py | 35 - app/utils/logger.py | 8 - {app/utils => backend}/__init__.py | 0 backend/annotation/__init__.py | 0 backend/application/signin_hub.py | 117 + backend/business/signin_manager.py | 414 + backend/infra/__init__.py | 0 backend/infra/api_key_introspect_handler.py | 52 + backend/infra/auth/user_auth_handler.py | 355 + backend/infra/permission/__init__.py | 0 .../infra/permission/permission_handler.py | 179 + backend/infra/permission/role_handler.py | 195 + backend/infra/permission/user_role_handler.py | 65 + .../user_profile/user_profile_handler.py | 121 + backend/models/__init__.py | 8 + backend/models/base_doc.py | 415 + backend/models/permission/__init__.py | 3 + backend/models/permission/constants.py | 26 + backend/models/permission/models.py | 53 + backend/models/user/__init__.py | 17 + backend/models/user/constants.py | 79 + backend/models/user/models.py | 80 + backend/models/user_profile/__init__.py | 3 + backend/models/user_profile/models.py | 103 + backend/services/auth/user_auth_service.py | 60 + .../services/code_depot/code_depot_service.py | 132 + .../notification/notification_service.py | 37 + .../services/permission/permission_service.py | 47 + backend/services/permission/role_service.py | 56 + .../services/user/user_management_service.py | 117 + common/__init__.py | 0 common/config/__init__.py | 0 common/config/app_settings.py | 38 + common/config/log_settings.py | 17 + common/constants/__init__.py | 0 common/constants/jwt_constants.py | 2 + common/constants/region.py | 5 + common/exception/__init__.py | 0 common/exception/exceptions.py | 23 + common/log/__init__.py | 0 common/log/application_logger.py | 12 + common/log/base_logger.py | 136 + common/log/business_metric_logger.py | 25 + common/log/function_logger.py | 50 + common/log/json_sink.py | 85 + common/log/log_utils.py | 25 + common/log/module_logger.py | 46 + common/log/user_logger.py | 14 + common/probes/__init__.py | 140 + common/probes/adapters.py | 15 + common/token/token_manager.py | 130 + common/utils/date.py | 22 + common/utils/region.py | 13 + common/utils/string.py | 87 + local.env | 14 + main.py | 16 - requirements.txt | 22 +- start_fastapi.sh | 26 + tests/__init__.py | 0 tests/api_tests/__init__.py | 0 tests/api_tests/permission/README.md | 86 + tests/api_tests/permission/__init__.py | 0 tests/api_tests/permission/conftest.py | 21 + .../permission/test_create_permission.py | 143 + .../permission/test_delete_permission.py | 85 + .../permission/test_query_permission.py | 57 + .../permission/test_update_permission.py | 205 + tests/api_tests/role/README.md | 99 + tests/api_tests/role/__init__.py | 0 tests/api_tests/role/conftest.py | 21 + .../api_tests/role/test_assign_permissions.py | 163 + tests/api_tests/role/test_create_role.py | 159 + tests/api_tests/role/test_delete_role.py | 91 + tests/api_tests/role/test_query_role.py | 58 + tests/api_tests/role/test_update_role.py | 233 + .../log/$APP_NAME-activity.log | 36 + tests/api_tests/siginin/README.md | 37 + tests/api_tests/siginin/__init__.py | 0 tests/api_tests/siginin/config.py | 2 + tests/api_tests/siginin/conftest.py | 10 + .../authentication-application-activity.log | 0 .../test_signin_with_email_and_password.py | 30 + tests/api_tests/user/README.md | 45 + tests/api_tests/user/__init__.py | 0 tests/api_tests/user/conftest.py | 21 + tests/api_tests/user/test_assign_roles.py | 100 + tests/base/__init__.py | 0 tests/base/authentication_web.py | 157 + tests/base/config.py | 5 + tests/conftest.py | 10 + tests/role_manage_coverage_report.md | 28 + tests/unit_tests/__init__.py | 0 tests/unit_tests/backend/__init__.py | 0 tests/unit_tests/backend/infra/__init__.py | 0 .../backend/infra/permission/__init__.py | 0 .../permission/permission_handler/__init__.py | 0 .../test_permission_handler.py | 137 + .../infra/permission/role_handler/__init__.py | 0 .../role_handler/test_role_handler.py | 169 + .../permission/user_role_handler/__init__.py | 0 .../test_user_role_handler.py | 43 + .../unit_tests/backend/permission/__init__.py | 0 .../permission/permission_service/__init__.py | 0 .../test_permission_service.py | 152 + .../permission/role_service/__init__.py | 0 .../role_service/test_role_service.py | 172 + .../permission/user_role_handler/__init__.py | 0 tests/unit_tests/backend/user/__init__.py | 0 .../user/user_management_service/__init__.py | 0 .../authentication-application-activity.log | 54 + .../test_user_management_service.py | 172 + tests/util/__init__.py | 0 tests/util/temporary_email.py | 76 + web/.editorconfig | 9 - web/.gitattributes | 1 - web/.gitignore | 33 - web/.prettierrc.json | 6 - web/docker-entrypoint.sh | 34 - web/e2e/tsconfig.json | 4 - web/e2e/vue.spec.ts | 8 - web/env.d.ts | 1 - web/eslint.config.ts | 34 - web/index.html | 13 - web/nginx/default.conf | 44 - web/package-lock.json | 6676 ----------------- web/package.json | 47 - web/playwright.config.ts | 110 - web/public/favicon.ico | Bin 4286 -> 0 bytes web/src/App.vue | 85 - web/src/assets/base.css | 86 - web/src/assets/logo.svg | 1 - web/src/assets/main.css | 35 - web/src/components/HelloWorld.vue | 41 - web/src/components/TheWelcome.vue | 94 - web/src/components/WelcomeItem.vue | 87 - .../components/__tests__/HelloWorld.spec.ts | 11 - web/src/components/icons/IconCommunity.vue | 7 - .../components/icons/IconDocumentation.vue | 7 - web/src/components/icons/IconEcosystem.vue | 7 - web/src/components/icons/IconSupport.vue | 7 - web/src/components/icons/IconTooling.vue | 19 - web/src/main.ts | 14 - web/src/router/index.ts | 23 - web/src/stores/counter.ts | 12 - web/src/views/AboutView.vue | 15 - web/src/views/HomeView.vue | 9 - web/tsconfig.app.json | 12 - web/tsconfig.json | 14 - web/tsconfig.node.json | 19 - web/tsconfig.vitest.json | 11 - web/vite.config.ts | 20 - web/vitest.config.ts | 14 - webapi/__init__.py | 0 webapi/bootstrap/application.py | 88 + webapi/bootstrap/freeleaps_app.py | 6 + webapi/config/site_settings.py | 25 + webapi/main.py | 32 + webapi/middleware/__init__.py | 4 + webapi/middleware/database_middleware.py | 89 + .../middleware/freeleaps_auth_middleware.py | 191 + webapi/providers/common.py | 31 + webapi/providers/database.py | 241 + webapi/providers/exception_handler.py | 47 + webapi/providers/logger.py | 8 + webapi/providers/metrics.py | 13 + webapi/providers/middleware.py | 11 + webapi/providers/probes.py | 20 + webapi/providers/router.py | 34 + webapi/providers/scheduler.py | 8 + webapi/routes/__init__.py | 16 + webapi/routes/api.py | 15 + webapi/routes/auth/__init__.py | 9 + webapi/routes/auth/send_email_code.py | 41 + webapi/routes/auth/send_mobile_code.py | 40 + webapi/routes/permission/__init__.py | 18 + .../permission/create_or_update_permission.py | 45 + webapi/routes/permission/create_permission.py | 43 + webapi/routes/permission/delete_permission.py | 32 + webapi/routes/permission/query_permission.py | 50 + .../query_permission_no_pagination.py | 45 + webapi/routes/permission/update_permission.py | 45 + webapi/routes/role/__init__.py | 18 + webapi/routes/role/assign_permissions.py | 40 + webapi/routes/role/create_or_update_role.py | 49 + webapi/routes/role/create_role.py | 45 + webapi/routes/role/delete_role.py | 32 + webapi/routes/role/query_role.py | 52 + .../routes/role/query_role_no_pagination.py | 47 + webapi/routes/role/update_role.py | 46 + webapi/routes/signin/__init__.py | 25 + .../signin/reset_password_through_email.py | 37 + webapi/routes/signin/sign_out.py | 40 + .../signin/signin_with_email_and_code.py | 97 + .../signin/signin_with_email_and_password.py | 92 + .../signin_with_magicleaps_email_and_code.py | 97 + .../try_magicleaps_signin_with_email.py | 37 + webapi/routes/signin/try_signin_with_email.py | 37 + webapi/routes/signin/update_new_user_flid.py | 54 + webapi/routes/signin/update_user_password.py | 55 + .../signin/update_user_password_no_depot.py | 55 + webapi/routes/tokens/__init__.py | 12 + webapi/routes/tokens/generate_tokens.py | 35 + webapi/routes/tokens/refresh_token.py | 35 + webapi/routes/tokens/verify_token.py | 27 + webapi/routes/user/__init__.py | 6 + webapi/routes/user/assign_roles.py | 36 + 241 files changed, 10695 insertions(+), 8631 deletions(-) delete mode 100644 .env.example delete mode 100644 .freeleaps/devops/api-server.Dockerfile delete mode 100644 .freeleaps/devops/helm-pkg/templates/_helpers.tpl delete mode 100644 .freeleaps/devops/helm-pkg/templates/api-server.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/authentication-config.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/certificate.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/dashboard.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/deployment.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/freeleapssecret.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/ingress.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/opentelemetry-rbac.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/opentelemetry.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/service.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/servicemonitor.yaml create mode 100644 .freeleaps/devops/helm-pkg/templates/authentication/vpa.yaml delete mode 100644 .freeleaps/devops/helm-pkg/templates/serviceaccount.yaml delete mode 100644 .freeleaps/devops/helm-pkg/templates/web-server.yaml delete mode 100644 .freeleaps/devops/nginx/default.conf delete mode 100644 .freeleaps/devops/nginx/docker-entrypoint.sh delete mode 100644 .freeleaps/devops/web-server.Dockerfile create mode 100644 Dockerfile delete mode 100644 README.md rename app/modules/sys/__init__.py => __init__.py (100%) delete mode 100644 app/modules/__init__.py delete mode 100644 app/modules/sys/routes.py delete mode 100644 app/routes.py delete mode 100644 app/schema.py delete mode 100644 app/setup_app.py delete mode 100644 app/utils/config.py delete mode 100644 app/utils/logger.py rename {app/utils => backend}/__init__.py (100%) create mode 100644 backend/annotation/__init__.py create mode 100644 backend/application/signin_hub.py create mode 100644 backend/business/signin_manager.py create mode 100644 backend/infra/__init__.py create mode 100644 backend/infra/api_key_introspect_handler.py create mode 100644 backend/infra/auth/user_auth_handler.py create mode 100644 backend/infra/permission/__init__.py create mode 100644 backend/infra/permission/permission_handler.py create mode 100644 backend/infra/permission/role_handler.py create mode 100644 backend/infra/permission/user_role_handler.py create mode 100644 backend/infra/user_profile/user_profile_handler.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/base_doc.py create mode 100644 backend/models/permission/__init__.py create mode 100644 backend/models/permission/constants.py create mode 100644 backend/models/permission/models.py create mode 100644 backend/models/user/__init__.py create mode 100644 backend/models/user/constants.py create mode 100644 backend/models/user/models.py create mode 100644 backend/models/user_profile/__init__.py create mode 100644 backend/models/user_profile/models.py create mode 100644 backend/services/auth/user_auth_service.py create mode 100644 backend/services/code_depot/code_depot_service.py create mode 100644 backend/services/notification/notification_service.py create mode 100644 backend/services/permission/permission_service.py create mode 100644 backend/services/permission/role_service.py create mode 100644 backend/services/user/user_management_service.py create mode 100644 common/__init__.py create mode 100644 common/config/__init__.py create mode 100644 common/config/app_settings.py create mode 100644 common/config/log_settings.py create mode 100644 common/constants/__init__.py create mode 100644 common/constants/jwt_constants.py create mode 100644 common/constants/region.py create mode 100644 common/exception/__init__.py create mode 100644 common/exception/exceptions.py create mode 100644 common/log/__init__.py create mode 100644 common/log/application_logger.py create mode 100644 common/log/base_logger.py create mode 100644 common/log/business_metric_logger.py create mode 100644 common/log/function_logger.py create mode 100644 common/log/json_sink.py create mode 100644 common/log/log_utils.py create mode 100644 common/log/module_logger.py create mode 100644 common/log/user_logger.py create mode 100644 common/probes/__init__.py create mode 100644 common/probes/adapters.py create mode 100644 common/token/token_manager.py create mode 100644 common/utils/date.py create mode 100644 common/utils/region.py create mode 100644 common/utils/string.py create mode 100644 local.env delete mode 100644 main.py create mode 100755 start_fastapi.sh create mode 100644 tests/__init__.py create mode 100644 tests/api_tests/__init__.py create mode 100644 tests/api_tests/permission/README.md create mode 100644 tests/api_tests/permission/__init__.py create mode 100644 tests/api_tests/permission/conftest.py create mode 100644 tests/api_tests/permission/test_create_permission.py create mode 100644 tests/api_tests/permission/test_delete_permission.py create mode 100644 tests/api_tests/permission/test_query_permission.py create mode 100644 tests/api_tests/permission/test_update_permission.py create mode 100644 tests/api_tests/role/README.md create mode 100644 tests/api_tests/role/__init__.py create mode 100644 tests/api_tests/role/conftest.py create mode 100644 tests/api_tests/role/test_assign_permissions.py create mode 100644 tests/api_tests/role/test_create_role.py create mode 100644 tests/api_tests/role/test_delete_role.py create mode 100644 tests/api_tests/role/test_query_role.py create mode 100644 tests/api_tests/role/test_update_role.py create mode 100644 tests/api_tests/siginin/${CODEBASE_ROOT}/log/$APP_NAME-activity.log create mode 100644 tests/api_tests/siginin/README.md create mode 100644 tests/api_tests/siginin/__init__.py create mode 100644 tests/api_tests/siginin/config.py create mode 100644 tests/api_tests/siginin/conftest.py create mode 100644 tests/api_tests/siginin/log/authentication-application-activity.log create mode 100644 tests/api_tests/siginin/test_signin_with_email_and_password.py create mode 100644 tests/api_tests/user/README.md create mode 100644 tests/api_tests/user/__init__.py create mode 100644 tests/api_tests/user/conftest.py create mode 100644 tests/api_tests/user/test_assign_roles.py create mode 100644 tests/base/__init__.py create mode 100644 tests/base/authentication_web.py create mode 100644 tests/base/config.py create mode 100644 tests/conftest.py create mode 100644 tests/role_manage_coverage_report.md create mode 100644 tests/unit_tests/__init__.py create mode 100644 tests/unit_tests/backend/__init__.py create mode 100644 tests/unit_tests/backend/infra/__init__.py create mode 100644 tests/unit_tests/backend/infra/permission/__init__.py create mode 100644 tests/unit_tests/backend/infra/permission/permission_handler/__init__.py create mode 100644 tests/unit_tests/backend/infra/permission/permission_handler/test_permission_handler.py create mode 100644 tests/unit_tests/backend/infra/permission/role_handler/__init__.py create mode 100644 tests/unit_tests/backend/infra/permission/role_handler/test_role_handler.py create mode 100644 tests/unit_tests/backend/infra/permission/user_role_handler/__init__.py create mode 100644 tests/unit_tests/backend/infra/permission/user_role_handler/test_user_role_handler.py create mode 100644 tests/unit_tests/backend/permission/__init__.py create mode 100644 tests/unit_tests/backend/permission/permission_service/__init__.py create mode 100644 tests/unit_tests/backend/permission/permission_service/test_permission_service.py create mode 100644 tests/unit_tests/backend/permission/role_service/__init__.py create mode 100644 tests/unit_tests/backend/permission/role_service/test_role_service.py create mode 100644 tests/unit_tests/backend/permission/user_role_handler/__init__.py create mode 100644 tests/unit_tests/backend/user/__init__.py create mode 100644 tests/unit_tests/backend/user/user_management_service/__init__.py create mode 100644 tests/unit_tests/backend/user/user_management_service/log/authentication-application-activity.log create mode 100644 tests/unit_tests/backend/user/user_management_service/test_user_management_service.py create mode 100644 tests/util/__init__.py create mode 100644 tests/util/temporary_email.py delete mode 100644 web/.editorconfig delete mode 100644 web/.gitattributes delete mode 100644 web/.gitignore delete mode 100644 web/.prettierrc.json delete mode 100644 web/docker-entrypoint.sh delete mode 100644 web/e2e/tsconfig.json delete mode 100644 web/e2e/vue.spec.ts delete mode 100644 web/env.d.ts delete mode 100644 web/eslint.config.ts delete mode 100644 web/index.html delete mode 100644 web/nginx/default.conf delete mode 100644 web/package-lock.json delete mode 100644 web/package.json delete mode 100644 web/playwright.config.ts delete mode 100644 web/public/favicon.ico delete mode 100644 web/src/App.vue delete mode 100644 web/src/assets/base.css delete mode 100644 web/src/assets/logo.svg delete mode 100644 web/src/assets/main.css delete mode 100644 web/src/components/HelloWorld.vue delete mode 100644 web/src/components/TheWelcome.vue delete mode 100644 web/src/components/WelcomeItem.vue delete mode 100644 web/src/components/__tests__/HelloWorld.spec.ts delete mode 100644 web/src/components/icons/IconCommunity.vue delete mode 100644 web/src/components/icons/IconDocumentation.vue delete mode 100644 web/src/components/icons/IconEcosystem.vue delete mode 100644 web/src/components/icons/IconSupport.vue delete mode 100644 web/src/components/icons/IconTooling.vue delete mode 100644 web/src/main.ts delete mode 100644 web/src/router/index.ts delete mode 100644 web/src/stores/counter.ts delete mode 100644 web/src/views/AboutView.vue delete mode 100644 web/src/views/HomeView.vue delete mode 100644 web/tsconfig.app.json delete mode 100644 web/tsconfig.json delete mode 100644 web/tsconfig.node.json delete mode 100644 web/tsconfig.vitest.json delete mode 100644 web/vite.config.ts delete mode 100644 web/vitest.config.ts create mode 100644 webapi/__init__.py create mode 100644 webapi/bootstrap/application.py create mode 100644 webapi/bootstrap/freeleaps_app.py create mode 100644 webapi/config/site_settings.py create mode 100755 webapi/main.py create mode 100644 webapi/middleware/__init__.py create mode 100644 webapi/middleware/database_middleware.py create mode 100644 webapi/middleware/freeleaps_auth_middleware.py create mode 100644 webapi/providers/common.py create mode 100644 webapi/providers/database.py create mode 100644 webapi/providers/exception_handler.py create mode 100644 webapi/providers/logger.py create mode 100644 webapi/providers/metrics.py create mode 100644 webapi/providers/middleware.py create mode 100644 webapi/providers/probes.py create mode 100644 webapi/providers/router.py create mode 100644 webapi/providers/scheduler.py create mode 100644 webapi/routes/__init__.py create mode 100644 webapi/routes/api.py create mode 100644 webapi/routes/auth/__init__.py create mode 100644 webapi/routes/auth/send_email_code.py create mode 100644 webapi/routes/auth/send_mobile_code.py create mode 100644 webapi/routes/permission/__init__.py create mode 100644 webapi/routes/permission/create_or_update_permission.py create mode 100644 webapi/routes/permission/create_permission.py create mode 100644 webapi/routes/permission/delete_permission.py create mode 100644 webapi/routes/permission/query_permission.py create mode 100644 webapi/routes/permission/query_permission_no_pagination.py create mode 100644 webapi/routes/permission/update_permission.py create mode 100644 webapi/routes/role/__init__.py create mode 100644 webapi/routes/role/assign_permissions.py create mode 100644 webapi/routes/role/create_or_update_role.py create mode 100644 webapi/routes/role/create_role.py create mode 100644 webapi/routes/role/delete_role.py create mode 100644 webapi/routes/role/query_role.py create mode 100644 webapi/routes/role/query_role_no_pagination.py create mode 100644 webapi/routes/role/update_role.py create mode 100644 webapi/routes/signin/__init__.py create mode 100644 webapi/routes/signin/reset_password_through_email.py create mode 100644 webapi/routes/signin/sign_out.py create mode 100644 webapi/routes/signin/signin_with_email_and_code.py create mode 100644 webapi/routes/signin/signin_with_email_and_password.py create mode 100644 webapi/routes/signin/signin_with_magicleaps_email_and_code.py create mode 100644 webapi/routes/signin/try_magicleaps_signin_with_email.py create mode 100644 webapi/routes/signin/try_signin_with_email.py create mode 100644 webapi/routes/signin/update_new_user_flid.py create mode 100644 webapi/routes/signin/update_user_password.py create mode 100644 webapi/routes/signin/update_user_password_no_depot.py create mode 100644 webapi/routes/tokens/__init__.py create mode 100644 webapi/routes/tokens/generate_tokens.py create mode 100644 webapi/routes/tokens/refresh_token.py create mode 100644 webapi/routes/tokens/verify_token.py create mode 100644 webapi/routes/user/__init__.py create mode 100644 webapi/routes/user/assign_roles.py diff --git a/.env.example b/.env.example deleted file mode 100644 index a52f97a..0000000 --- a/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# API Settings -APP_VERSION=1.0.0 -ENV=dev - -# Server Settings -UVICORN_HOST=0.0.0.0 -UVICORN_PORT=8888 - -# CORS Settings -BACKEND_CORS_ORIGINS=http://localhost:3000,http://localhost:8080,http://localhost:5173 - -# Application Settings -PROJECT_NAME=freeleaps-authentication - -# Logging -LOGGING_LEVEL=INFO diff --git a/.freeleaps/devops/api-server.Dockerfile b/.freeleaps/devops/api-server.Dockerfile deleted file mode 100644 index cbb2463..0000000 --- a/.freeleaps/devops/api-server.Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# Use Python 3.10 slim image as base -FROM python:3.10-slim - -# Set working directory -WORKDIR /app - -# Set environment variables -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - POETRY_VERSION=1.7.1 - -# Install system dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gcc \ - python3-dev \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements file -COPY requirements.txt . - -# Install Python dependencies -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Expose the port the app runs on -EXPOSE 8888 - -# Command to run the application -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8888"] \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/Chart.yaml b/.freeleaps/devops/helm-pkg/Chart.yaml index 46769d5..8b4c559 100644 --- a/.freeleaps/devops/helm-pkg/Chart.yaml +++ b/.freeleaps/devops/helm-pkg/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 -name: freeleaps-authentication -description: A Helm chart for FastAPI and Vue.js application +name: authentication +description: A Helm Chart of authentication, which part of Freeleaps Platform, powered by Freeleaps. type: application -version: 0.1.0 -appVersion: "1.0.0" +version: 0.0.1 +appVersion: "0.0.1" diff --git a/.freeleaps/devops/helm-pkg/templates/_helpers.tpl b/.freeleaps/devops/helm-pkg/templates/_helpers.tpl deleted file mode 100644 index b61d44d..0000000 --- a/.freeleaps/devops/helm-pkg/templates/_helpers.tpl +++ /dev/null @@ -1,62 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "app.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "app.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "app.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "app.labels" -}} -helm.sh/chart: {{ include "app.chart" . }} -{{ include "app.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "app.selectorLabels" -}} -app.kubernetes.io/name: {{ include "app.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Create the name of the service account to use -*/}} -{{- define "app.serviceAccountName" -}} -{{- if .Values.serviceAccount.create }} -{{- default (include "app.fullname" .) .Values.serviceAccount.name }} -{{- else }} -{{- default "default" .Values.serviceAccount.name }} -{{- end }} -{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/api-server.yaml b/.freeleaps/devops/helm-pkg/templates/api-server.yaml deleted file mode 100644 index 4127e80..0000000 --- a/.freeleaps/devops/helm-pkg/templates/api-server.yaml +++ /dev/null @@ -1,58 +0,0 @@ -{{- if .Values.freeleapsAuthenticationApiServer.enabled -}} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Release.Name }}-api-server - labels: - {{- include "app.labels" . | nindent 4 }} - component: api-server -spec: - replicas: {{ .Values.freeleapsAuthenticationApiServer.replicas }} - selector: - matchLabels: - {{- include "app.selectorLabels" . | nindent 6 }} - component: api-server - template: - metadata: - labels: - {{- include "app.selectorLabels" . | nindent 8 }} - component: api-server - spec: - serviceAccountName: {{ include "app.serviceAccountName" . }} - containers: - - name: api-server - image: "{{ .Values.freeleapsAuthenticationApiServer.image.registry | default .Values.global.registry }}/{{ .Values.freeleapsAuthenticationApiServer.image.repository | default .Values.global.repository }}/{{ .Values.freeleapsAuthenticationApiServer.image.name }}:{{ .Values.freeleapsAuthenticationApiServer.image.tag }}" - imagePullPolicy: {{ .Values.freeleapsAuthenticationApiServer.image.imagePullPolicy }} - ports: - {{- toYaml .Values.freeleapsAuthenticationApiServer.ports | nindent 12 }} - resources: - {{- toYaml .Values.freeleapsAuthenticationApiServer.resources | nindent 12 }} - env: - {{- toYaml .Values.freeleapsAuthenticationApiServer.env | nindent 12 }} - {{- with .Values.freeleapsAuthenticationApiServer.livenessProbe }} - livenessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.freeleapsAuthenticationApiServer.readinessProbe }} - readinessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} ---- -apiVersion: v1 -kind: Service -metadata: - name: freeleaps-authentication-api-server - labels: - {{- include "app.labels" . | nindent 4 }} - component: api-server -spec: - type: {{ .Values.service.type }} - ports: - - port: 8888 - targetPort: http - protocol: TCP - name: api-server - selector: - {{- include "app.selectorLabels" . | nindent 4 }} - component: api-server -{{- end }} diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/authentication-config.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/authentication-config.yaml new file mode 100644 index 0000000..b92f984 --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/authentication-config.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Secret +metadata: + name: authentication-config + namespace: {{ .Release.Namespace }} +type: Opaque +data: + TZ: {{ .Values.authentication.configs.tz | b64enc | quote }} + APP_NAME: {{ .Values.authentication.configs.appName | b64enc | quote }} + APP_ENV: {{ .Values.authentication.configs.appEnv | b64enc | quote }} + DEVSVC_WEBAPI_URL_BASE: {{ .Values.authentication.configs.devsvcWebapiUrlBase | b64enc | quote }} + NOTIFICATION_WEBAPI_URL_BASE: {{ .Values.authentication.configs.notificationWebapiUrlBase | b64enc | quote }} + AUTH_SERVICE_ENDPOINT: {{ .Values.authentication.configs.authServiceEndpoint | b64enc | quote }} + JWT_ALGORITHM: {{ .Values.authentication.configs.jwtAlgorithm | b64enc | quote }} + SERVICE_API_ACCESS_HOST: {{ .Values.authentication.configs.serviceApiAccessHost | b64enc | quote }} + SERVICE_API_ACCESS_PORT: {{ .Values.authentication.configs.serviceApiAccessPort | toString | b64enc }} + MONGODB_NAME: {{ .Values.authentication.configs.mongodbName | b64enc | quote }} + MONGODB_PORT: {{ .Values.authentication.configs.mongodbPort | toString | b64enc }} + METRICS_ENABLED: {{ .Values.authentication.configs.metricsEnabled | default false | toString | b64enc }} + PROBES_ENABLED: {{ .Values.authentication.configs.probesEnabled | default false | toString | b64enc }} + \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/certificate.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/certificate.yaml new file mode 100644 index 0000000..5d9bb8e --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/certificate.yaml @@ -0,0 +1,27 @@ +{{ $namespace := .Release.Namespace }} +{{ $appVersion := .Chart.AppVersion | quote }} +{{ $releaseCertificate := .Release.Service }} +{{ $releaseName := .Release.Name }} +{{- range $ingress := .Values.authentication.ingresses }} +{{- if not $ingress.tls.exists }} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ $ingress.name }} + namespace: {{ $namespace }} + labels: + app.kubernetes.io/version: {{ $appVersion }} + app.kubernetes.io/name: {{ $ingress.name | quote }} + app.kubernetes.io/managed-by: {{ $releaseCertificate }} + app.kubernetes.io/instance: {{ $releaseName }} +spec: + commonName: {{ $ingress.host }} + dnsNames: + - {{ $ingress.host }} + issuerRef: + name: {{ $ingress.tls.issuerRef.name }} + kind: {{ $ingress.tls.issuerRef.kind }} + secretName: {{ $ingress.tls.name }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/dashboard.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/dashboard.yaml new file mode 100644 index 0000000..aee092f --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/dashboard.yaml @@ -0,0 +1,865 @@ +{{- if .Values.dashboard.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.dashboard.name }} + namespace: {{ .Values.dashboard.namespace }} + labels: + grafana_dashboard: "1" +data: + {{ .Values.dashboard.name }}.json: | + { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 36, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 9, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "$$hashKey": "object:214", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "increase({{ .Values.dashboard.metricsPrefix }}_http_requests_total[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ `{{ method }} {{ handler }}` }}", + "range": true, + "refId": "A" + } + ], + "title": "Total requests per minute", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "4xx" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "HTTP 500" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#bf1b00", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 9, + "y": 0 + }, + "id": 13, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "$$hashKey": "object:140", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "sum by (status) (rate({{ .Values.dashboard.metricsPrefix }}_http_requests_total[1m]))", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ `{{ status }}` }}", + "range": true, + "refId": "A" + } + ], + "title": "Request per minute", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "errors" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#c15c17", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 5, + "x": 19, + "y": 0 + }, + "id": 4, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "$$hashKey": "object:766", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "sum(rate({{ .Values.dashboard.metricsPrefix }}_http_requests_total{status=\"5xx\"}[1m]))", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "errors", + "range": true, + "refId": "A" + } + ], + "title": "Errors per second", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 0, + "y": 7 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "$$hashKey": "object:146", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "{{ .Values.dashboard.metricsPrefix }}_http_request_duration_seconds_sum{handler!=\"none\"} / {{ .Values.dashboard.metricsPrefix }}_http_request_duration_seconds_count", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ `{{ handler }}` }}", + "range": true, + "refId": "A" + } + ], + "title": "Average response time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "none" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 10, + "x": 9, + "y": 7 + }, + "id": 11, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "$$hashKey": "object:1079", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "increase({{ .Values.dashboard.metricsPrefix }}_http_request_duration_seconds_bucket{le=\"0.1\"}[1m]) \n/ ignoring (le) increase({{ .Values.dashboard.metricsPrefix }}_http_request_duration_seconds_count[1m])", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ `{{ handler }}` }}", + "refId": "A" + } + ], + "title": "Requests under 100ms", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line+area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "transparent", + "value": null + }, + { + "color": "red", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 9, + "x": 0, + "y": 15 + }, + "id": 16, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "$$hashKey": "object:426", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.9, rate({{ .Values.dashboard.metricsPrefix }}_http_request_duration_seconds_bucket{handler!=\"none\"}[1m]))", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ `{{ handler }}` }}", + "range": true, + "refId": "A" + } + ], + "title": "Request duration [s] - p90", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 25, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 9, + "y": 15 + }, + "id": 15, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "$$hashKey": "object:426", + "datasource": { + "type": "prometheus", + "uid": "e4584a9f-5364-4b3d-a851-7abbc5250820" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5, rate({{ .Values.dashboard.metricsPrefix }}_http_request_duration_seconds_bucket{handler!=\"none\"}[1m]))", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{ `{{ handler }}` }}", + "range": true, + "refId": "A" + } + ], + "title": "Request duration [s] - p50", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "5s", + "schemaVersion": 40, + "tags": ["freeleaps"], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [] + }, + "timezone": "", + "title": "{{ .Values.dashboard.title }}", + "uid": "", + "version": 11, + "weekStart": "" + } +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/deployment.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/deployment.yaml new file mode 100644 index 0000000..6855bfc --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/deployment.yaml @@ -0,0 +1,131 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} + app.kubernetes.io/name: "authentication" + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Values.logIngest.enabled }} + annotations: + opentelemetry.io/config-checksum: {{ include (print $.Template.BasePath "/opentelemetry.yaml") . | sha256sum }} +{{- end }} + name: "authentication" + namespace: {{ .Release.Namespace | quote }} +spec: + selector: + matchLabels: + app.kubernetes.io/name: "authentication" + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + replicas: {{ .Values.authentication.replicas }} + template: + metadata: + labels: + app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} + app.kubernetes.io/name: "authentication" + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + app.kubernetes.io/config-checksum: {{ include (print $.Template.BasePath "/authentication-config.yaml") . | sha256sum }} +{{- if .Values.logIngest.enabled }} + opentelemetry.io/config-checksum: {{ include (print $.Template.BasePath "/opentelemetry.yaml") . | sha256sum }} + sidecar.opentelemetry.io/inject: "{{ .Release.Namespace}}/{{ .Release.Name }}-opentelemetry-collector" +{{- end }} + spec: +{{- if .Values.logIngest.enabled }} + serviceAccountName: "{{ .Release.Name }}-otel-collector" +{{- end }} + containers: + - name: "authentication" + image: "{{ coalesce .Values.authentication.image.registry .Values.global.registry "docker.io"}}/{{ coalesce .Values.authentication.image.repository .Values.global.repository }}/{{ .Values.authentication.image.name }}:{{ .Values.authentication.image.tag | default "latest" }}" + imagePullPolicy: {{ .Values.authentication.image.imagePullPolicy | default "IfNotPresent" }} + ports: + {{- range $port := .Values.authentication.ports }} + - containerPort: {{ $port.containerPort }} + name: {{ $port.name }} + protocol: {{ $port.protocol }} + {{- end }} + {{- if .Values.authentication.resources }} + resources: + {{- toYaml .Values.authentication.resources | nindent 12 }} + {{- end }} + {{- if .Values.authentication.probes }} + {{- if and (.Values.authentication.probes.liveness) (eq .Values.authentication.probes.liveness.type "httpGet") }} + livenessProbe: + httpGet: + path: {{ .Values.authentication.probes.liveness.config.path }} + port: {{ .Values.authentication.probes.liveness.config.port }} + {{- if .Values.authentication.probes.liveness.config.initialDelaySeconds }} + initialDelaySeconds: {{ .Values.authentication.probes.liveness.config.initialDelaySeconds }} + {{- end }} + {{- if .Values.authentication.probes.liveness.config.periodSeconds }} + periodSeconds: {{ .Values.authentication.probes.liveness.config.periodSeconds }} + {{- end }} + {{- if .Values.authentication.probes.liveness.config.timeoutSeconds }} + timeoutSeconds: {{ .Values.authentication.probes.liveness.config.timeoutSeconds }} + {{- end }} + {{- if .Values.authentication.probes.liveness.config.successThreshold }} + successThreshold: {{ .Values.authentication.probes.liveness.config.successThreshold }} + {{- end }} + {{- if .Values.authentication.probes.liveness.config.failureThreshold }} + failureThreshold: {{ .Values.authentication.probes.liveness.config.failureThreshold }} + {{- end }} + {{- if .Values.authentication.probes.liveness.config.terminationGracePeriodSeconds }} + terminationGracePeriodSeconds: {{ .Values.authentication.probes.liveness.config.terminationGracePeriodSeconds }} + {{- end }} + {{- end }} + {{- if and (.Values.authentication.probes.readiness) (eq .Values.authentication.probes.readiness.type "httpGet") }} + readinessProbe: + httpGet: + path: {{ .Values.authentication.probes.readiness.config.path }} + port: {{ .Values.authentication.probes.readiness.config.port }} + {{- if .Values.authentication.probes.readiness.config.initialDelaySeconds }} + initialDelaySeconds: {{ .Values.authentication.probes.readiness.config.initialDelaySeconds }} + {{- end }} + {{- if .Values.authentication.probes.readiness.config.periodSeconds }} + periodSeconds: {{ .Values.authentication.probes.readiness.config.periodSeconds }} + {{- end }} + {{- if .Values.authentication.probes.readiness.config.timeoutSeconds }} + timeoutSeconds: {{ .Values.authentication.probes.readiness.config.timeoutSeconds }} + {{- end }} + {{- if .Values.authentication.probes.readiness.config.successThreshold }} + successThreshold: {{ .Values.authentication.probes.readiness.config.successThreshold }} + {{- end }} + {{- if .Values.authentication.probes.readiness.config.failureThreshold }} + failureThreshold: {{ .Values.authentication.probes.readiness.config.failureThreshold }} + {{- end }} + {{- end }} + {{- end}} + env: + {{- range $key, $value := .Values.authentication.configs }} + {{- if not (or (eq $key "jwtSecretKey") (eq $key "mongodbUri")) }} + - name: {{ $key | snakecase | upper }} + valueFrom: + secretKeyRef: + name: authentication-config + key: {{ $key | snakecase | upper }} + {{- end }} + {{- end }} + # inject from secret created by FreeleapsSecret object + {{- if .Values.authentication.secrets }} + {{ $targetSecretName := .Values.authentication.secrets.target.name }} + {{- range .Values.authentication.secrets.data }} + - name: {{ .key | snakecase | upper }} + valueFrom: + secretKeyRef: + name: {{ $targetSecretName }} + key: {{ .key }} + {{- end }} + {{- end }} +{{- if .Values.logIngest.enabled }} + volumeMounts: + - name: app-logs + mountPath: {{ .Values.logIngest.logPath }} +{{- end }} +{{- if .Values.logIngest.enabled }} + volumes: + - name: app-logs + emptyDir: {} +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/freeleapssecret.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/freeleapssecret.yaml new file mode 100644 index 0000000..e986020 --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/freeleapssecret.yaml @@ -0,0 +1,20 @@ +apiVersion: freeleaps.com/v1alpha1 +kind: FreeleapsSecret +metadata: + name: freeleaps-authentication-secrets + namespace: {{ .Release.Namespace }} +spec: + secretStoreRef: + kind: {{ .Values.authentication.secrets.secretStoreRef.kind }} + name: {{ .Values.authentication.secrets.secretStoreRef.name }} + target: + name: {{ .Values.authentication.secrets.target.name }} + creationPolicy: {{ .Values.authentication.secrets.target.creationPolicy }} + refreshInterval: {{ .Values.authentication.secrets.refreshInterval }} + data: +{{- range .Values.authentication.secrets.data }} + - secretKey: {{ .key }} + remoteRef: + key: {{ .remoteRef.key }} + type: {{ .remoteRef.type }} +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/ingress.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/ingress.yaml new file mode 100644 index 0000000..c685a5f --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/ingress.yaml @@ -0,0 +1,36 @@ +{{ $namespace := .Release.Namespace }} +{{ $appVersion := .Chart.AppVersion | quote }} +{{ $releaseIngress := .Release.Service }} +{{ $releaseName := .Release.Name }} +{{- range $ingress := .Values.authentication.ingresses }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $ingress.name }} + namespace: {{ $namespace }} + labels: + app.kubernetes.io/version: {{ $appVersion }} + app.kubernetes.io/name: {{ $ingress.name | quote }} + app.kubernetes.io/managed-by: {{ $releaseIngress }} + app.kubernetes.io/instance: {{ $releaseName }} +spec: +{{- if $ingress.class }} + ingressClassName: {{ $ingress.class }} +{{- end }} +{{- if $ingress.tls }} + tls: + - hosts: + - {{ $ingress.host }} +{{- if $ingress.tls.exists }} + secretName: {{ $ingress.tls.secretRef.name }} +{{- else }} + secretName: {{ $ingress.tls.name }} +{{- end }} +{{- end }} + rules: + - host: {{ $ingress.host }} + http: + paths: +{{- toYaml $ingress.rules | nindent 10 }} +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/opentelemetry-rbac.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/opentelemetry-rbac.yaml new file mode 100644 index 0000000..8d25fa6 --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/opentelemetry-rbac.yaml @@ -0,0 +1,46 @@ +{{- if .Values.logIngest.enabled }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Release.Name }}-otel-collector + namespace: {{ .Release.Namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-otel-collector +rules: + - apiGroups: [""] + resources: + - pods + - namespaces + - nodes + verbs: + - get + - watch + - list + - apiGroups: ["apps"] + resources: + - replicasets + - deployments + - statefulsets + - daemonsets + verbs: + - get + - watch + - list +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Release.Name }}-otel-collector +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Release.Name }}-otel-collector +subjects: + - kind: ServiceAccount + name: {{ .Release.Name }}-otel-collector + namespace: {{ .Release.Namespace }} +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/opentelemetry.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/opentelemetry.yaml new file mode 100644 index 0000000..f4730db --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/opentelemetry.yaml @@ -0,0 +1,119 @@ +{{- if .Values.logIngest.enabled }} +apiVersion: opentelemetry.io/v1beta1 +kind: OpenTelemetryCollector +metadata: + name: {{ .Release.Name }}-opentelemetry-collector + namespace: {{ .Release.Namespace }} +spec: + mode: sidecar + image: ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib:latest + serviceAccount: "{{ .Release.Name }}-otel-collector" + volumeMounts: + - name: app-logs + mountPath: {{ .Values.logIngest.logPath }} + securityContext: + allowPrivilegeEscalation: true + privileged: true + runAsUser: 0 + runAsGroup: 0 + env: + - name: KUBE_META_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: KUBE_META_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: KUBE_META_NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + - name: KUBE_META_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: KUBE_META_POD_UID + valueFrom: + fieldRef: + fieldPath: metadata.uid + - name: KUBE_META_OBJECT_NAME + valueFrom: + fieldRef: + fieldPath: metadata.labels['app.kubernetes.io/instance'] + config: + receivers: + filelog: + include: + - {{ .Values.logIngest.logPathPattern }} + start_at: beginning + include_file_path: false + include_file_name: false + operators: [] + processors: + resource: + attributes: + - action: insert + key: k8s.node.name + value: ${KUBE_META_NODE_NAME} + - action: insert + key: k8s.pod.name + value: ${KUBE_META_POD_NAME} + - action: insert + key: k8s.pod.ip + value: ${KUBE_META_POD_IP} + - action: insert + key: k8s.pod.uid + value: ${KUBE_META_POD_UID} + - action: insert + key: k8s.namespace.name + value: ${KUBE_META_NAMESPACE} + - action: insert + key: k8s.deployment.name + value: ${KUBE_META_OBJECT_NAME} + transform: + log_statements: + - context: log + statements: + # Set Grafana queryable labels + - set(resource.attributes["service_name"], "authentication") + - set(resource.attributes["environment"], "{{ .Values.global.environment | default .Release.Namespace }}") + - set(resource.attributes["pod_name"], resource.attributes["k8s.pod.name"]) + - set(resource.attributes["pod_ip"], resource.attributes["k8s.pod.ip"]) + # Keep application for backward compatibility + - set(resource.attributes["application"], "authentication") + # Set additional kubernetes labels + - set(resource.attributes["kubernetes_node_name"], resource.attributes["k8s.node.name"]) + - set(resource.attributes["kubernetes_pod_name"], resource.attributes["k8s.pod.name"]) + - set(resource.attributes["kubernetes_pod_ip"], resource.attributes["k8s.pod.ip"]) + - set(resource.attributes["kubernetes_deployment_name"], resource.attributes["k8s.deployment.name"]) + - set(resource.attributes["kubernetes_namespace"], resource.attributes["k8s.namespace.name"]) + # Parse and enrich log body + - set(resource.attributes["body_json"], ParseJSON(log.body)) + - set(resource.attributes["body_json"]["kubernetes"]["pod"], resource.attributes["k8s.pod.name"]) + - set(resource.attributes["body_json"]["kubernetes"]["namespace"], resource.attributes["k8s.namespace.name"]) + - set(resource.attributes["body_json"]["kubernetes"]["pod_ip"], resource.attributes["k8s.pod.ip"]) + - set(resource.attributes["body_json"]["kubernetes"]["pod_uid"], resource.attributes["k8s.pod.uid"]) + - set(resource.attributes["body_json"]["kubernetes"]["deployment"], resource.attributes["k8s.deployment.name"]) + - set(resource.attributes["body_json"]["kubernetes"]["node"], resource.attributes["k8s.node.name"]) + - set(resource.attributes["body_json"]["kubernetes"]["namespace"], resource.attributes["k8s.namespace.name"]) + - set(log.body, resource.attributes["body_json"]) + - delete_key(resource.attributes, "body_json") + batch: + send_batch_size: 5 + timeout: 10s + exporters: + otlphttp/logs: + endpoint: {{ .Values.logIngest.lokiEndpoint }}/otlp + tls: + insecure: true + service: + telemetry: + logs: + level: info + pipelines: + logs: + receivers: [filelog] + processors: [resource, transform, batch] + exporters: [otlphttp/logs] +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/service.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/service.yaml new file mode 100644 index 0000000..cacd94d --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/service.yaml @@ -0,0 +1,26 @@ +{{ $namespace := .Release.Namespace }} +{{ $appVersion := .Chart.AppVersion | quote }} +{{ $releaseService := .Release.Service }} +{{ $releaseName := .Release.Name }} +{{- range $service := .Values.authentication.services }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $service.name }} + namespace: {{ $namespace }} + labels: + app.kubernetes.io/version: {{ $appVersion }} + app.kubernetes.io/name: {{ $service.name | quote }} + app.kubernetes.io/managed-by: {{ $releaseService }} + app.kubernetes.io/instance: {{ $releaseName }} +spec: + ports: + - port: {{ $service.port }} + targetPort: {{ $service.targetPort }} + selector: + app.kubernetes.io/version: {{ $appVersion }} + app.kubernetes.io/name: "authentication" + app.kubernetes.io/managed-by: {{ $releaseService }} + app.kubernetes.io/instance: {{ $releaseName }} +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/servicemonitor.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/servicemonitor.yaml new file mode 100644 index 0000000..7c8854b --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/servicemonitor.yaml @@ -0,0 +1,40 @@ +{{ $namespace := .Release.Namespace }} +{{ $appVersion := .Chart.AppVersion | quote }} +{{ $releaseService := .Release.Service }} +{{ $releaseName := .Release.Name }} + +{{- range $service := .Values.authentication.services }} +{{- if $service.serviceMonitor.enabled }} +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ $service.name }}-monitor + namespace: {{ $service.serviceMonitor.namespace }} + labels: + app.kubernetes.io/version: {{ $appVersion }} + app.kubernetes.io/name: {{ $service.name }}-monitor + app.kubernetes.io/managed-by: {{ $releaseService }} + app.kubernetes.io/instance: {{ $releaseName }} + {{- if $service.serviceMonitor.labels }} + {{- toYaml $service.serviceMonitor.labels | nindent 4 }} + {{- end }} +spec: + endpoints: + - path: /api/_/metrics + targetPort: {{ $service.targetPort }} + {{- if $service.serviceMonitor.interval }} + interval: {{ $service.serviceMonitor.interval }} + {{- end }} + {{- if $service.serviceMonitor.scrapeTimeout }} + scrapeTimeout: {{ $service.serviceMonitor.scrapeTimeout }} + {{- end }} + namespaceSelector: + matchNames: + - {{ $namespace | quote }} + selector: + matchLabels: + app.kubernetes.io/name: {{ $service.name }} + app.kubernetes.io/instance: {{ $releaseName }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/authentication/vpa.yaml b/.freeleaps/devops/helm-pkg/templates/authentication/vpa.yaml new file mode 100644 index 0000000..d4a3f23 --- /dev/null +++ b/.freeleaps/devops/helm-pkg/templates/authentication/vpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.authentication.vpa }} +--- +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: {{ .Release.Name }}-vpa + namespace: {{ .Release.Namespace }} +spec: + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: authentication + resourcePolicy: + containerPolicies: + - containerName: '*' + {{- if .Values.authentication.vpa.minAllowed.enabled }} + minAllowed: + cpu: {{ .Values.authentication.vpa.minAllowed.cpu }} + memory: {{ .Values.authentication.vpa.minAllowed.memory }} + {{- end }} + {{- if .Values.authentication.vpa.maxAllowed.enabled }} + maxAllowed: + cpu: {{ .Values.authentication.vpa.maxAllowed.cpu }} + memory: {{ .Values.authentication.vpa.maxAllowed.memory }} + {{- end }} + {{- if .Values.authentication.vpa.controlledResources }} + controlledResources: + {{- range .Values.authentication.vpa.controlledResources }} + - {{ . }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/.freeleaps/devops/helm-pkg/templates/serviceaccount.yaml b/.freeleaps/devops/helm-pkg/templates/serviceaccount.yaml deleted file mode 100644 index cd18400..0000000 --- a/.freeleaps/devops/helm-pkg/templates/serviceaccount.yaml +++ /dev/null @@ -1,8 +0,0 @@ -{{- if .Values.serviceAccount.create -}} -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ include "app.serviceAccountName" . }} - labels: - {{- include "app.labels" . | nindent 4 }} -{{- end }} diff --git a/.freeleaps/devops/helm-pkg/templates/web-server.yaml b/.freeleaps/devops/helm-pkg/templates/web-server.yaml deleted file mode 100644 index 210336b..0000000 --- a/.freeleaps/devops/helm-pkg/templates/web-server.yaml +++ /dev/null @@ -1,58 +0,0 @@ -{{- if .Values.freeleapsAuthenticationWebServer.enabled -}} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Release.Name }}-web-server - labels: - {{- include "app.labels" . | nindent 4 }} - component: web-server -spec: - replicas: {{ .Values.freeleapsAuthenticationWebServer.replicas }} - selector: - matchLabels: - {{- include "app.selectorLabels" . | nindent 6 }} - component: web-server - template: - metadata: - labels: - {{- include "app.selectorLabels" . | nindent 8 }} - component: web-server - spec: - serviceAccountName: {{ include "app.serviceAccountName" . }} - containers: - - name: web-server - image: "{{ .Values.freeleapsAuthenticationWebServer.image.registry | default .Values.global.registry }}/{{ .Values.freeleapsAuthenticationWebServer.image.repository | default .Values.global.repository }}/{{ .Values.freeleapsAuthenticationWebServer.image.name }}:{{ .Values.freeleapsAuthenticationWebServer.image.tag }}" - imagePullPolicy: {{ .Values.freeleapsAuthenticationWebServer.image.imagePullPolicy }} - ports: - {{- toYaml .Values.freeleapsAuthenticationWebServer.ports | nindent 12 }} - resources: - {{- toYaml .Values.freeleapsAuthenticationWebServer.resources | nindent 12 }} - env: - {{- toYaml .Values.freeleapsAuthenticationWebServer.env | nindent 12 }} - {{- with .Values.freeleapsAuthenticationWebServer.livenessProbe }} - livenessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.freeleapsAuthenticationWebServer.readinessProbe }} - readinessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} ---- -apiVersion: v1 -kind: Service -metadata: - name: freeleaps-authentication-web-server - labels: - {{- include "app.labels" . | nindent 4 }} - component: web-server -spec: - type: {{ .Values.service.type }} - ports: - - port: 80 - targetPort: http - protocol: TCP - name: web-server - selector: - {{- include "app.selectorLabels" . | nindent 4 }} - component: web-server -{{- end }} diff --git a/.freeleaps/devops/helm-pkg/values.alpha.yaml b/.freeleaps/devops/helm-pkg/values.alpha.yaml index 97d74cb..b6e49a7 100644 --- a/.freeleaps/devops/helm-pkg/values.alpha.yaml +++ b/.freeleaps/devops/helm-pkg/values.alpha.yaml @@ -1,100 +1,147 @@ -# Default values for the Helm chart -# This is a YAML-formatted file. -# Declare variables to be passed into your templates. - -# Global settings +dashboard: + enabled: false +logIngest: + enabled: true + lokiEndpoint: http://loki-gateway.freeleaps-logging-system + logPathPattern: /app/log/authentication/*.log + logPath: /app/log/authentication global: + environment: alpha registry: docker.io - repository: freeleapsdevops - nodeSelector: {} - -# Name override settings -nameOverride: "" -fullnameOverride: "" - -freeleapsAuthenticationApiServer: - enabled: true - name: freeleaps-authentication-api-server + repository: freeleaps +authentication: replicas: 1 image: - registry: "" - repository: "" - name: api-server - tag: latest + registry: docker.io + repository: null + name: authentication + tag: snapshot-512e418 imagePullPolicy: IfNotPresent ports: - - name: http - containerPort: 8888 - protocol: TCP + - name: http + containerPort: 8004 + protocol: TCP resources: requests: - cpu: "0.2" - memory: "256Mi" + cpu: 50m + memory: 64Mi limits: - cpu: "0.5" - memory: "512Mi" - env: - - name: PYTHONUNBUFFERED - value: "1" - - name: DEBUG - value: "false" - livenessProbe: - httpGet: - path: /api/v1/_/livez - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /api/v1/_/readyz - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - -freeleapsAuthenticationWebServer: - enabled: true - name: freeleaps-authentication-web-server - replicas: 1 - image: - registry: "" - repository: "" - name: web-server - tag: latest - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 80 - protocol: TCP - resources: - requests: - cpu: "0.1" - memory: "128Mi" - limits: - cpu: "0.3" - memory: "256Mi" - env: - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: API_SERVER_URL - value: "http://freeleaps-authentication-api-server.svc.freeleaps.cluster:8888" - livenessProbe: - httpGet: - path: / - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: / - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - -serviceAccount: - create: true - name: "" - -service: - type: ClusterIP + cpu: 200m + memory: 128Mi + probes: + readiness: + type: httpGet + config: + path: /api/_/readyz + port: 8004 + initialDelaySeconds: 5 + periodSeconds: 30 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 3 + liveness: + type: httpGet + config: + path: /api/_/livez + port: 8004 + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + services: + - name: authentication-service + type: ClusterIP + port: 8004 + targetPort: 8004 + serviceMonitor: + enabled: false + ingresses: + - name: authentication-ingress + host: authentication.freeleaps-alpha.com + class: nginx + rules: + - path: / + pathType: Prefix + backend: + service: + name: authentication-service + port: + number: 8004 + tls: + exists: false + issuerRef: + name: freeleaps-alpha-dot-com + kind: ClusterIssuer + name: authentication.freeleaps-alpha.com-cert + configs: + tz: UTC + appName: authentication + appEnv: alpha + devsvcWebapiUrlBase: http://devsvc-service.freeleaps-alpha.svc.freeleaps.cluster:8007/api/devsvc/ + notificationWebapiUrlBase: http://notification-service.freeleaps-alpha.svc.freeleaps.cluster:8003/api/notification/ + authServiceEndpoint: http://freeleaps-auth-service.68c0da88a0a7837e84b580eb-alpha.svc.freeleaps.cluster:9000/api/v1/ + jwtAlgorithm: HS256 + serviceApiAccessHost: 0.0.0.0 + serviceApiAccessPort: 8004 + mongodbName: freeleaps2 + mongodbPort: 27017 + metricsEnabled: 'false' + probesEnabled: 'true' + secrets: + secretStoreRef: + kind: FreeleapsSecretStore + name: freeleaps-main-secret-store + target: + name: freeleaps-authentication-secrets + creationPolicy: Owner + refreshInterval: 30s + data: + - key: jwtSecretKey + remoteRef: + key: freeleaps-alpha-jwt-secret-key + type: Secret + - key: mongodbUri + remoteRef: + key: freeleaps-alpha-mongodb-uri + type: Secret + vpa: + minAllowed: + enabled: false + cpu: 100m + memory: 64Mi + maxAllowed: + enabled: true + cpu: 100m + memory: 256Mi + controlledResources: + - cpu + - memory + prometheusRule: + name: freepeals-alpha-authentication + enabled: false + namespace: freeleaps-monitoring-system + labels: + release: kube-prometheus-stack + rules: + - alert: FreeleapsAuthenticationServiceDown + expr: up{job="authentication-service"} == 0 + for: 1m + labels: + severity: critical + service: authentication-service + annotations: + summary: Freeleaps Authentication service is down (instance {{ $labels.instance }}) + description: Freeleaps Authentication service has been down for more than 1 minutes. + runbook_url: https://netorgft10898514.sharepoint.com/:w:/s/FreeleapsEngineeringTeam/EUlvzumTsPxCpPAzI3gm9OIB0DCLTjQzzYVL6VsHYZFjxg?e=0dxVr7 + - alert: FreeleapsAuthenticationServiceHighErrorRate + expr: rate(http_requests_total{job="authentication-service",status=~"5.."}[5m]) > 0.1 + for: 5m + labels: + severity: warning + service: authentication-service + annotations: + summary: High error rate in freeleaps authentication service (instance {{ $labels.instance }}) + description: Freeleaps Authentication service error rate is {{ $value }} errors per second. + runbook_url: https://netorgft10898514.sharepoint.com/:w:/s/FreeleapsEngineeringTeam/EUlvzumTsPxCpPAzI3gm9OIB0DCLTjQzzYVL6VsHYZFjxg?e=0dxVr7 diff --git a/.freeleaps/devops/helm-pkg/values.prod.yaml b/.freeleaps/devops/helm-pkg/values.prod.yaml index 97d74cb..f753a8a 100644 --- a/.freeleaps/devops/helm-pkg/values.prod.yaml +++ b/.freeleaps/devops/helm-pkg/values.prod.yaml @@ -1,100 +1,138 @@ -# Default values for the Helm chart -# This is a YAML-formatted file. -# Declare variables to be passed into your templates. - -# Global settings +dashboard: + enabled: true + name: freeleaps-prod-authentication-dashboard + title: Authentication Service Dashboard (PROD) + metricsPrefix: freeleaps_authentication +logIngest: + enabled: true + lokiEndpoint: http://loki-gateway.freeleaps-logging-system + logPathPattern: /app/log/authentication/*.log + logPath: /app/log/authentication global: + environment: prod registry: docker.io - repository: freeleapsdevops - nodeSelector: {} - -# Name override settings -nameOverride: "" -fullnameOverride: "" - -freeleapsAuthenticationApiServer: - enabled: true - name: freeleaps-authentication-api-server + repository: freeleaps +authentication: replicas: 1 image: - registry: "" - repository: "" - name: api-server - tag: latest + registry: docker.io + repository: null + name: authentication + tag: 1.15.0 imagePullPolicy: IfNotPresent ports: - - name: http - containerPort: 8888 - protocol: TCP + - name: http + containerPort: 8004 + protocol: TCP resources: requests: - cpu: "0.2" - memory: "256Mi" + cpu: 200m + memory: 64Mi limits: - cpu: "0.5" - memory: "512Mi" - env: - - name: PYTHONUNBUFFERED - value: "1" - - name: DEBUG - value: "false" - livenessProbe: - httpGet: - path: /api/v1/_/livez - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /api/v1/_/readyz - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - -freeleapsAuthenticationWebServer: - enabled: true - name: freeleaps-authentication-web-server - replicas: 1 - image: - registry: "" - repository: "" - name: web-server - tag: latest - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 80 - protocol: TCP - resources: - requests: - cpu: "0.1" - memory: "128Mi" - limits: - cpu: "0.3" - memory: "256Mi" - env: - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: API_SERVER_URL - value: "http://freeleaps-authentication-api-server.svc.freeleaps.cluster:8888" - livenessProbe: - httpGet: - path: / - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: / - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - -serviceAccount: - create: true - name: "" - -service: - type: ClusterIP + cpu: 300m + memory: 128Mi + probes: + readiness: + type: httpGet + config: + path: /api/_/readyz + port: 8004 + initialDelaySeconds: 5 + periodSeconds: 30 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 3 + liveness: + type: httpGet + config: + path: /api/_/livez + port: 8004 + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + services: + - name: authentication-service + type: ClusterIP + port: 8004 + targetPort: 8004 + serviceMonitor: + enabled: true + labels: + release: kube-prometheus-stack + namespace: freeleaps-monitoring-system + interval: 30s + scrapeTimeout: '' + ingresses: {} + configs: + tz: UTC + appName: authentication + appEnv: prod + devsvcWebapiUrlBase: http://devsvc-service.freeleaps-prod.svc.freeleaps.cluster:8007/api/devsvc/ + notificationWebapiUrlBase: http://notification-service.freeleaps-prod.svc.freeleaps.cluster:8003/api/notification/ + authServiceEndpoint: http://freeleaps-auth-service.68c0da88a0a7837e84b580eb-prod.svc.freeleaps.cluster:9000/api/v1/ + jwtAlgorithm: HS256 + serviceApiAccessHost: 0.0.0.0 + serviceApiAccessPort: 8004 + mongodbName: freeleaps2 + mongodbPort: 27017 + metricsEnabled: 'true' + probesEnabled: 'true' + secrets: + secretStoreRef: + kind: FreeleapsSecretStore + name: freeleaps-main-secret-store + target: + name: freeleaps-authentication-prod-secrets + creationPolicy: Owner + refreshInterval: 30s + data: + - key: jwtSecretKey + remoteRef: + key: freeleaps-prod-jwt-secret-key + type: Secret + - key: mongodbUri + remoteRef: + key: freeleaps-prod-mongodb-uri + type: Secret + vpa: + minAllowed: + enabled: true + cpu: 50m + memory: 64Mi + maxAllowed: + enabled: true + cpu: 200m + memory: 128Mi + controlledResources: + - cpu + - memory + prometheusRule: + name: freepeals-prod-authentication + enabled: true + namespace: freeleaps-monitoring-system + labels: + release: kube-prometheus-stack + rules: + - alert: FreeleapsAuthenticationServiceDown + expr: up{job="authentication-service"} == 0 + for: 5m + labels: + severity: critical + service: authentication-service + annotations: + summary: Freeleaps Authentication service is down (instance {{ $labels.instance }}) + description: Freeleaps Authentication service has been down for more than 1 minutes. + runbook_url: https://netorgft10898514.sharepoint.com/:w:/s/FreeleapsEngineeringTeam/EUlvzumTsPxCpPAzI3gm9OIB0DCLTjQzzYVL6VsHYZFjxg?e=0dxVr7 + - alert: FreeleapsAuthenticationServiceHighErrorRate + expr: rate(http_requests_total{job="authentication-service",status=~"5.."}[5m]) > 0.1 + for: 5m + labels: + severity: warning + service: authentication-service + annotations: + summary: High error rate in freeleaps authentication service (instance {{ $labels.instance }}) + description: Freeleaps Authentication service error rate is {{ $value }} errors per second. + runbook_url: https://netorgft10898514.sharepoint.com/:w:/s/FreeleapsEngineeringTeam/EUlvzumTsPxCpPAzI3gm9OIB0DCLTjQzzYVL6VsHYZFjxg?e=0dxVr7 diff --git a/.freeleaps/devops/helm-pkg/values.yaml b/.freeleaps/devops/helm-pkg/values.yaml index 8f5e61a..a77b97d 100644 --- a/.freeleaps/devops/helm-pkg/values.yaml +++ b/.freeleaps/devops/helm-pkg/values.yaml @@ -1,100 +1,114 @@ -# Default values for the Helm chart -# This is a YAML-formatted file. -# Declare variables to be passed into your templates. - -# Global settings global: registry: docker.io - repository: freeleapsdevops + repository: freeleaps nodeSelector: {} - -# Name override settings -nameOverride: "" -fullnameOverride: "" - -freeleapsAuthenticationApiServer: - enabled: true - name: freeleaps-authentication-api-server +dashboard: + enabled: false + name: freeleaps-prod-authentication-dashboard + title: Authentication Service Dashboard + metricsPrefix: freeleaps_authentication +logIngest: + enabled: false + lokiEndpoint: http://loki-gateway.freeleaps-logging-system + logPathPattern: /app/log/authentication/*.log + logPath: /app/log/authentication +fluentbit: + enabled: false + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 512Mi + image: kubesphere/fluent-bit:v4.0-debug + imagePullPolicy: IfNotPresent + timeKey: record.repr + timeFormat: "%Y-%m-%dT%H:%M:%S.%LZ" + logPath: /app/log/authentication/*.log +authentication: replicas: 1 image: - registry: "" - repository: "" - name: api-server - tag: latest + registry: + repository: freeleaps + name: authentication + tag: 1.0.0 imagePullPolicy: IfNotPresent ports: - name: http - containerPort: 8888 + containerPort: 8004 protocol: TCP resources: requests: - cpu: "0.2" - memory: "256Mi" - limits: cpu: "0.5" memory: "512Mi" - env: - - name: PYTHONUNBUFFERED - value: "1" - - name: DEBUG - value: "false" - livenessProbe: - httpGet: - path: /api/v1/_/livez - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /api/v1/_/readyz - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - -freeleapsAuthenticationWebServer: - enabled: true - name: freeleaps-authentication-web-server - replicas: 1 - image: - registry: "" - repository: "" - name: web-server - tag: latest - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 80 - protocol: TCP - resources: - requests: - cpu: "0.1" - memory: "128Mi" limits: - cpu: "0.3" - memory: "256Mi" - env: - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: API_SERVER_URL - value: "http://freeleaps-authentication-api-server.svc.cluster.local:8888/" - livenessProbe: - httpGet: - path: / - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: / - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - -serviceAccount: - create: true - name: "" - -service: - type: ClusterIP + cpu: "1" + memory: "1Gi" + # FIXME: Wait until the developers implements the probes APIs + probes: {} + services: + - name: authentication-service + type: ClusterIP + port: 8004 + targetPort: 8004 + serviceMonitor: + enabled: false + labels: + release: kube-prometheus-stack + namespace: freeleaps-monitoring-system + interval: 30s + scrapeTimeout: "" + # Defaults to {}, which means doesn't have any ingress + ingresses: {} + configs: + # TZ + tz: "America/Settle" + # APP_NAME + appName: "authentication" + # AUTH_SERVICE_ENDPOINT + authServiceEndpoint: "" + # DEVSVC_WEBAPI_URL_BASE + devsvcWebapiUrlBase: "http://devsvc..svc.freeleaps.cluster:/api/devsvc" + # NOTIFICATION_WEBAPI_URL_BASE + notificationWebapiUrlBase: "http://notification.svc..freeleaps.cluster:/api/notification" + # JWT_ALGORITHM + jwtAlgorithm: "HS256" + # MONGODB_NAME + mongodbName: "" + # MONGODB_PORT + mongodbPort: "27017" + # METRICS_ENABLED + metricsEnabled: "false" + # PROBES_ENABLED + probesEnabled: "false" + # AKV secrets configuration + secrets: + secretStoreRef: + kind: FreeleapsSecretStore + name: freeleaps-main-secret-store + target: + name: "freeleaps-authentication-secrets" + creationPolicy: "Owner" + refreshInterval: 30s + data: + - key: jwtSecretKey + remoteRef: + key: "freeleaps-jwt-secret-key" + type: Secret + - key: mongodbUri + remoteRef: + key: "freeleaps-mongodb-uri" + type: Secret + vpa: + minAllowed: + enabled: false + cpu: 100m + memory: 64Mi + maxAllowed: + enabled: true + cpu: 100m + memory: 256Mi + controlledResources: + - cpu + - memory \ No newline at end of file diff --git a/.freeleaps/devops/nginx/default.conf b/.freeleaps/devops/nginx/default.conf deleted file mode 100644 index 13945e7..0000000 --- a/.freeleaps/devops/nginx/default.conf +++ /dev/null @@ -1,44 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Gzip compression - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - gzip_min_length 1000; - gzip_proxied any; - - # Cache control for static assets - location /assets/ { - expires 1y; - add_header Cache-Control "public, no-transform"; - access_log off; - } - - # Handle Vue router history mode - location / { - try_files $uri $uri/ /index.html; - expires -1; - add_header Cache-Control "no-store, no-cache, must-revalidate"; - } - - # Security headers - add_header X-Frame-Options "SAMEORIGIN"; - add_header X-XSS-Protection "1; mode=block"; - add_header X-Content-Type-Options "nosniff"; - - # Proxy API requests to the backend - location /api/ { - proxy_pass ${API_SERVER_URL}; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/.freeleaps/devops/nginx/docker-entrypoint.sh b/.freeleaps/devops/nginx/docker-entrypoint.sh deleted file mode 100644 index a321c5a..0000000 --- a/.freeleaps/devops/nginx/docker-entrypoint.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -set -e - -# Default value for API_SERVER_URL if not provided -API_SERVER_URL=${API_SERVER_URL:-http://api-server:8888/} - -# Replace the environment variable in the nginx config -envsubst '${API_SERVER_URL}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf - -# Start nginx -exec nginx -g 'daemon off;' diff --git a/.freeleaps/devops/web-server.Dockerfile b/.freeleaps/devops/web-server.Dockerfile deleted file mode 100644 index 54339ee..0000000 --- a/.freeleaps/devops/web-server.Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# Use nginx alpine as base image -FROM nginx:stable-alpine - -# Install envsubst -RUN apk add --no-cache gettext - -# Copy pre-built dist files into nginx -COPY dist /usr/share/nginx/html - -# Copy nginx configuration template and entry script -COPY nginx/default.conf /etc/nginx/conf.d/default.conf.template -COPY docker-entrypoint.sh /docker-entrypoint.sh - -# Make the entry script executable -RUN chmod ug+x /docker-entrypoint.sh - -# Set default environment variable -ENV API_SERVER_URL=http://api-server:8888/ - -# Expose port 80 -EXPOSE 80 - -# Use the entry script as the entrypoint -ENTRYPOINT ["/docker-entrypoint.sh"] \ No newline at end of file diff --git a/.freeleaps/project.alpha.yaml b/.freeleaps/project.alpha.yaml index 767aec9..798b339 100644 --- a/.freeleaps/project.alpha.yaml +++ b/.freeleaps/project.alpha.yaml @@ -8,41 +8,22 @@ spec: layout: FAST_API_VUE_3 serviceName: freeleaps-authentication executeMode: fully - branch: main + branch: dev components: - - name: freeleapsAuthenticationApiServer + - name: authentication root: '.' language: python dependenciesManager: pip requirementsFile: requirements.txt buildCacheEnabled: true - buildAgentImage: python:3.10-slim-buster + buildAgentImage: python:3.12-slim buildArtifacts: - '.' imageBuilder: dind - dockerfilePath: .freeleaps/devops/api-server.Dockerfile - imageName: freeleaps-authentication-api-server + dockerfilePath: Dockerfile + imageName: authentication imageBuildRoot: '.' imageReleaseArchitectures: - linux/amd64 - - linux/arm64/v8 - - name: freeleapsAuthenticationWebServer - root: 'web' - language: javascript - dependenciesManager: pnpm - pnpmPackageJsonFile: package.json - buildCacheEnabled: true - buildAgentImage: node:lts - buildCommand: 'pnpm -r build' - buildArtifacts: - - 'dist' - - 'public' - imageBuilder: dind - dockerfilePath: ../.freeleaps/devops/web-server.Dockerfile - imageName: freeleaps-authentication-web-server - imageBuildRoot: '.' - imageReleaseArchitectures: - - linux/amd64 - - linux/arm64/v8 diff --git a/.freeleaps/project.prod.yaml b/.freeleaps/project.prod.yaml index 767aec9..1e1efb5 100644 --- a/.freeleaps/project.prod.yaml +++ b/.freeleaps/project.prod.yaml @@ -10,39 +10,18 @@ spec: executeMode: fully branch: main components: - - name: freeleapsAuthenticationApiServer + - name: authentication root: '.' language: python dependenciesManager: pip requirementsFile: requirements.txt buildCacheEnabled: true - buildAgentImage: python:3.10-slim-buster + buildAgentImage: python:3.12-slim buildArtifacts: - '.' imageBuilder: dind - dockerfilePath: .freeleaps/devops/api-server.Dockerfile - imageName: freeleaps-authentication-api-server + dockerfilePath: Dockerfile + imageName: authentication imageBuildRoot: '.' imageReleaseArchitectures: - - linux/amd64 - - linux/arm64/v8 - - name: freeleapsAuthenticationWebServer - root: 'web' - language: javascript - dependenciesManager: pnpm - pnpmPackageJsonFile: package.json - buildCacheEnabled: true - buildAgentImage: node:lts - buildCommand: 'pnpm -r build' - buildArtifacts: - - 'dist' - - 'public' - imageBuilder: dind - dockerfilePath: ../.freeleaps/devops/web-server.Dockerfile - imageName: freeleaps-authentication-web-server - imageBuildRoot: '.' - imageReleaseArchitectures: - - linux/amd64 - - linux/arm64/v8 - - + - linux/amd64 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3ea6f52..c4f4b9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,54 +1,7 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual Environment +.idea +.vscode +__pycache__ venv/ -env/ -ENV/ .env -.venv - -# IDE -.idea/ -.vscode/ -*.swp -*.swo -.DS_Store - -# FastAPI -.pytest_cache/ -coverage.xml -.coverage -htmlcov/ - -# Logs -*.log -logs/ - -# Local development -.env.local -.env.development.local -.env.test.local -.env.production.local - -# Docker -docker-compose.override.yml +log/ +webapi/log/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91763b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.12-slim + +# docker settings +ARG CONTAINER_APP_ROOT="/app" +ENV APP_NAME="authentication" + +ENV DEVSVC_WEBAPI_URL_BASE="http://devsvc:8007/api/devsvc" +ENV NOTIFICATION_WEBAPI_URL_BASE="http://notification:8003/api/notification/" + +ENV JWT_SECRET_KEY="8f87ca8c3c9c3df09a9c78e0adb0927855568f6072d9efc892534aee35f5867b" +ENV JWT_ALGORITHM="HS256" + +#site_settings +ENV SERVICE_API_ACCESS_HOST=0.0.0.0 +ENV SERVICE_API_ACCESS_PORT=8004 +ENV MONGODB_NAME=freeleaps2 +ENV MONGODB_PORT=27017 +ENV MONGODB_URI="mongodb://localhost:27017/" + +# Freeleaps Auth Config +ENV AUTH_SERVICE_ENDPOINT="" + +#log_settings +ENV LOG_BASE_PATH=$CONTAINER_APP_ROOT/log/$APP_NAME +ENV BACKEND_LOG_FILE_NAME=$APP_NAME +ENV APPLICATION_ACTIVITY_LOG=$APP_NAME-activity + + +WORKDIR ${CONTAINER_APP_ROOT} +COPY requirements.txt . + +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +COPY . ${CONTAINER_APP_ROOT} + +EXPOSE ${SERVICE_API_ACCESS_PORT} +# Using shell to expand environemnt to enure pass the actual environment value to uvicorn +CMD uvicorn webapi.main:app --reload --port=$SERVICE_API_ACCESS_PORT --host=$SERVICE_API_ACCESS_HOST \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index db953dc..0000000 --- a/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# freeleaps-authentication - -This repo create with `FastAPI` and `Vue 3`, powered by `freeleaps.com`. - -> **Please do not delete files under `${PROJECT_ROOT}/.freeleaps`**, these files used to supports DevOps workflow. - -## Project Layout - -``` -├── .freeleaps/ # Freeleaps configuration -│ ├── devops/ # DevOps related configurations -│ └── project.yaml # Project configuration file -├── app/ # FastAPI backend application -│ ├── modules/ # Application modules -│ │ ├── sys/ # System related modules -│ │ └── __init__.py # Module initialization -│ ├── utils/ # Utility functions -│ ├── routes.py # API route definitions -│ ├── schema.py # Pydantic schemas -│ └── setup_app.py # Application setup and configuration -├── web/ # Vue 3 frontend application -│ ├── src/ # Source code -│ │ ├── assets/ # Static assets -│ │ ├── components/ # Vue components -│ │ ├── router/ # Vue router configuration -│ │ ├── stores/ # Pinia stores -│ │ ├── views/ # Page views -│ │ ├── App.vue # Root Vue component -│ │ └── main.ts # Application entry point -│ ├── public/ # Public static files -│ ├── e2e/ # End-to-end tests -│ ├── package.json # Node.js dependencies -│ ├── vite.config.ts # Vite configuration -│ ├── tsconfig.json # TypeScript configuration -│ └── README.md # Frontend specific documentation -├── main.py # FastAPI application entry point -├── requirements.txt # Python dependencies -├── .env.example # Environment variables template -├── .gitignore # Git ignore rules -└── README.md # This file -``` - -### Backend (FastAPI) -- **main.py**: Application entry point and server startup -- **app/**: Contains all backend application code - - **modules/**: Feature-based modules organization - - **routes.py**: API endpoint definitions - - **schema.py**: Data validation schemas using Pydantic - - **setup_app.py**: Application configuration and middleware setup - - **utils/**: Shared utility functions - -### Frontend (Vue 3) -- **web/**: Complete Vue 3 application with TypeScript - - **src/**: Source code with modern Vue 3 composition API - - **components/**: Reusable Vue components - - **views/**: Page-level components - - **router/**: Client-side routing configuration - - **stores/**: State management using Pinia - - **assets/**: Static assets like images, styles -- **Vite**: Fast build tool and development server -- **TypeScript**: Type-safe JavaScript development -- **ESLint & Prettier**: Code linting and formatting - -### Configuration -- **.freeleaps/**: Platform-specific configurations -- **.env.example**: Environment variables template -- **requirements.txt**: Python package dependencies -- **package.json**: Node.js dependencies and scripts diff --git a/app/modules/sys/__init__.py b/__init__.py similarity index 100% rename from app/modules/sys/__init__.py rename to __init__.py diff --git a/app/modules/__init__.py b/app/modules/__init__.py deleted file mode 100644 index 3a2179f..0000000 --- a/app/modules/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -import pathlib -import pkgutil -from importlib import import_module -from importlib.util import find_spec - -def _modules(postfix="") -> list: - """ - Get all modules in the current package. - """ - return [ - import_module(f".{name}{postfix}", package=__name__) - for (_, name, _) in pkgutil.iter_modules([str(pathlib.Path(__file__).parent)]) - if find_spec(f".{name}{postfix}", package=__name__) - ] - -def detect_modules() -> list: - """ - Detect all modules in the current package. - """ - return _modules(".modules") \ No newline at end of file diff --git a/app/modules/sys/routes.py b/app/modules/sys/routes.py deleted file mode 100644 index 2ab2be6..0000000 --- a/app/modules/sys/routes.py +++ /dev/null @@ -1,41 +0,0 @@ -from fastapi import APIRouter, status -from starlette.responses import JSONResponse - -from app.utils.config import settings -from app.schema import Response - -router = APIRouter() - -@router.get('/_/livez', status_code=status.HTTP_200_OK, response_model=Response) -async def liveness() -> JSONResponse: - """ - Liveness check probe endpoint. - You can modify the logic here to check the health of your application. - But do not modify the response format or remove this endpoint. - Its will break the health check of the deployment. - """ - return JSONResponse( - status_code=status.HTTP_200_OK, - content={ - 'code': status.HTTP_200_OK, - 'msg': 'ok', - 'payload': None - } - ) - -@router.get('/_/readyz', status_code=status.HTTP_200_OK, response_model=Response) -async def readiness() -> JSONResponse: - """ - Readiness check probe endpoint. - You can modify the logic here to check the health of your application. - But do not modify the response format or remove this endpoint. - Its will break the health check of the deployment. - """ - return JSONResponse( - status_code=status.HTTP_200_OK, - content={ - 'code': status.HTTP_200_OK, - 'msg': 'ok', - 'payload': None - } - ) \ No newline at end of file diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index da90155..0000000 --- a/app/routes.py +++ /dev/null @@ -1,25 +0,0 @@ -from fastapi import APIRouter, status - -from app.schema import Response -from app.modules.sys.routes import router as sys_router -from app.utils.config import settings - -api_router = APIRouter() -root_router = APIRouter() - -api_router.include_router( - sys_router, - tags=["System"], -) - -@root_router.get('/', status_code=status.HTTP_200_OK, response_model=Response) -def root() -> dict: - return { - 'code': status.HTTP_200_OK, - 'msg': 'ok', - 'payload': { - 'name': settings.PROJECT_NAME, - 'version': settings.APP_VERSION, - 'environment': settings.ENV, - } - } \ No newline at end of file diff --git a/app/schema.py b/app/schema.py deleted file mode 100644 index c3f3fa9..0000000 --- a/app/schema.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Any - -from pydantic import BaseModel - -class Response(BaseModel): - code: int - msg: str - payload: dict[Any, Any] | None = None \ No newline at end of file diff --git a/app/setup_app.py b/app/setup_app.py deleted file mode 100644 index e6de0b3..0000000 --- a/app/setup_app.py +++ /dev/null @@ -1,49 +0,0 @@ -from fastapi import FastAPI -from starlette.middleware.cors import CORSMiddleware - -from app.utils.config import settings -from app.utils.logger import logger -from app.routes import api_router, root_router - -def setup_routers(app: FastAPI) -> None: - # Register root router without prefix to handle root level routes - app.include_router(root_router) - # Register API router with configured prefix - app.include_router( - api_router, - prefix=settings.API_V1_STR, - ) - -def setup_cors(app: FastAPI) -> None: - origins = [] - - if settings.BACKEND_CORS_ORIGINS: - origins_raw = settings.BACKEND_CORS_ORIGINS.split(",") - - for origin in origins_raw: - use_origin = origin.strip() - origins.append(use_origin) - - logger.info(f"Allowed CORS origins: {origins}") - - app.user_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - -def create_app() -> FastAPI: - app = FastAPI( - title=settings.PROJECT_NAME, - version=settings.APP_VERSION, - docs_url=None if settings.is_production() else "/docs", - redoc_url=None if settings.is_production() else "/redoc", - openapi_url=f"{settings.API_V1_STR}/openapi.json", - ) - - setup_routers(app) - setup_cors(app) - - return app \ No newline at end of file diff --git a/app/utils/config.py b/app/utils/config.py deleted file mode 100644 index 1c1c84b..0000000 --- a/app/utils/config.py +++ /dev/null @@ -1,35 +0,0 @@ -from enum import Enum - -from pydantic_settings import BaseSettings - -class AppEnvironment(str, Enum): - PRODUCTION = "prod" - DEVELOPMENT = "dev" - TESTING = "test" - -class Config(BaseSettings): - - API_V1_STR: str = "/api/v1" - - APP_VERSION: str = "Unversioned API" - ENV: AppEnvironment = AppEnvironment.DEVELOPMENT - - UVICORN_HOST: str = "0.0.0.0" - UVICORN_PORT: int = 8888 - - BACKEND_CORS_ORIGINS: str = "" - - PROJECT_NAME: str = "freeleaps-authentication" - - LOGGING_LEVEL: str = "INFO" - - def is_development(self) -> bool: - return self.ENV == AppEnvironment.DEVELOPMENT - - def is_testing(self) -> bool: - return self.ENV == AppEnvironment.TESTING - - def is_production(self) -> bool: - return self.ENV == AppEnvironment.PRODUCTION - -settings = Config(_env_file=".env", _env_file_encoding="utf-8") \ No newline at end of file diff --git a/app/utils/logger.py b/app/utils/logger.py deleted file mode 100644 index 074508a..0000000 --- a/app/utils/logger.py +++ /dev/null @@ -1,8 +0,0 @@ -import logging - -from app.utils.config import settings - -formatter = "%(levelname)s: %(asctime)s - %(module)s - %(funcName)s - %(message)s" - -logger = logging.getLogger(__name__) -logging.basicConfig(level=settings.LOGGING_LEVEL, format=formatter) \ No newline at end of file diff --git a/app/utils/__init__.py b/backend/__init__.py similarity index 100% rename from app/utils/__init__.py rename to backend/__init__.py diff --git a/backend/annotation/__init__.py b/backend/annotation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/application/signin_hub.py b/backend/application/signin_hub.py new file mode 100644 index 0000000..834f302 --- /dev/null +++ b/backend/application/signin_hub.py @@ -0,0 +1,117 @@ +from typing import Optional, Tuple, List + +from backend.services.permission.permission_service import PermissionService +from backend.services.permission.role_service import RoleService +from common.constants.region import UserRegion +from common.log.log_utils import log_entry_exit_async +from backend.business.signin_manager import SignInManager +from backend.models.user.constants import UserLoginAction + + +class SignInHub: + def __init__(self) -> None: + self.signin_manager = SignInManager() + # TODO: Dax - Event dispatch and notification center + # self.notification_center = NotificationCenter(sender_id=settings.SYSTEM_USER_ID) + # self.event_dispatcher = UserEventDispatcher(owner_id=settings.SYSTEM_USER_ID) + + @log_entry_exit_async + async def signin_with_email_and_code( + self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC" + ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str], Optional[UserRegion], Optional[List[str]], + Optional[List[str]]]: + """ + Interacts with the business layer to handle the sign-in process with email and code. + Try to signin with email and code. + create a new user account, if the email address has never been used before. + + Args: + email (str): email address + code (str): auth code to be verified + host (str): the host address by which the client access the frontend service + time_zone (Optional[str]): time zone of the frontend service + Returns: + [int, Optional[int], Optional[str], Optional[str]]: + - int: UserLoginAction + - Optional[int]: user role + - Optional[str]: user_id + - Optional[str]: flid + - Optional[str]: region + - Optional[str]: user role names + - Optional[str]: user permission keys + """ + return await self.signin_manager.signin_with_email_and_code( + email=email, code=code, host=host, time_zone=time_zone + ) + + @log_entry_exit_async + async def signin_with_email_and_password( + self, email: str, password: str + ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str], Optional[List[str]], Optional[List[str]]]: + """Try to signin with email and password. + + Args: + email (str): email address + password (str): password to be verified + + Returns: + [int, Optional[int], Optional[str], Optional[str]]: + - int: UserLoginAction + - Optional[int]: user role + - Optional[str]: user_id + - Optional[str]: flid + - Optional[List[str]]: user role names + - Optional[List[str]]: user permission keys + """ + return await self.signin_manager.signin_with_email_and_password( + email=email, password=password + ) + + @log_entry_exit_async + async def update_new_user_flid( + self, user_id: str, user_flid: str + ) -> Tuple[UserLoginAction, Optional[str]]: + return await self.signin_manager.update_new_user_flid( + user_id=user_id, user_flid=user_flid + ) + + @log_entry_exit_async + async def try_signin_with_email(self, email: str, host: str) -> UserLoginAction: + return await self.signin_manager.try_signin_with_email(email=email, host=host) + + @log_entry_exit_async + async def try_magicleaps_signin_with_email(self, email: str, host: str) -> UserLoginAction: + return await self.signin_manager.try_magicleaps_signin_with_email(email=email, host=host) + + @log_entry_exit_async + async def reset_password_through_email(self, email: str, host: str) -> int: + return await self.signin_manager.reset_password_through_email( + email=email, host=host + ) + + @log_entry_exit_async + async def update_user_password(self, user_id: str, password: str) -> dict[str, any]: + return await self.signin_manager.update_user_password( + user_id=user_id, password=password + ) + + @log_entry_exit_async + async def update_user_password_no_depot(self, user_id: str, password: str) -> dict[str, any]: + return await self.signin_manager.update_user_password_no_depot( + user_id=user_id, password=password + ) + + @log_entry_exit_async + async def send_email_code(self, sender_id: str, email: str) -> dict[str, any]: + result = await self.signin_manager.send_email_code(sender_id, email) + return {"succeeded": result} + + @log_entry_exit_async + async def send_mobile_code(self, sender_id: str, mobile: str) -> dict[str, any]: + result = await self.signin_manager.send_mobile_code(sender_id, mobile) + return {"succeeded": result} + + @log_entry_exit_async + async def sign_out(self, identity: str) -> bool: + # TODO: to be implemented + return True diff --git a/backend/business/signin_manager.py b/backend/business/signin_manager.py new file mode 100644 index 0000000..2469024 --- /dev/null +++ b/backend/business/signin_manager.py @@ -0,0 +1,414 @@ +import random +from typing import Tuple, Optional, List + + +from backend.services.auth.user_auth_service import UserAuthService +from common.constants.region import UserRegion +from common.utils.region import RegionHandler +from backend.models.user.constants import ( + NewUserMethod, +) +from backend.models.user.constants import UserLoginAction +from backend.services.user.user_management_service import ( + UserManagementService, +) +from backend.services.code_depot.code_depot_service import ( + CodeDepotService, +) +from common.log.module_logger import ModuleLogger +from common.utils.string import check_password_complexity +from common.exception.exceptions import InvalidDataError +from backend.services.notification.notification_service import ( + NotificationService, +) +from backend.models.user.constants import ( + AuthType, +) +from common.config.app_settings import app_settings + + +class SignInManager: + def __init__(self): + self.user_auth_service = UserAuthService() + self.region_handler = RegionHandler() + self.user_management_service = UserManagementService() + self.module_logger = ModuleLogger(sender_id=SignInManager) + self.code_depot_service = CodeDepotService() + self.notification_service = NotificationService() + + async def signin_with_email_and_code( + self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC" + ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[UserRegion], Optional[str], Optional[List[str]], Optional[List[str]]]: + """Try to signin with email and code. + create a new user account, if the email address has never been used before. + + Args: + email (str): email address + code (str): auth code to be verified + host (str): the host address by which the client access the frontend service, for detecting UserRegion + time_zone (str, optional): timezone of the frontend service + + Returns: + [int, Optional[int], Optional[str], Optional[str]]: + - int: UserLoginAction + - Optional[int]: user role + - Optional[str]: user_id + - Optional[str]: flid + - Optional[str]: region + - Optional[str]: user role names + - Optional[str]: user permission keys + """ + # check if the user account exist + user_id = await self.user_auth_service.get_user_id_by_email(email) + + # if it cannot find user account according to the email address, new user + is_new_user = user_id is None + preferred_region = self.region_handler.detect_from_host(host) + + # verify the email through auth code + if await self.user_auth_service.verify_email_with_code(email, code): + if is_new_user: + user_account = ( + await self.user_management_service.create_new_user_account( + method=NewUserMethod.EMAIL, region=preferred_region + ) + ) + user_id = str(user_account.id) + await self.user_management_service.initialize_new_user_data( + user_id=str(user_account.id), + method=NewUserMethod.EMAIL, + email_address=email, + region=preferred_region, + time_zone=time_zone, + ) + + user_account = await self.user_management_service.get_account_by_id( + user_id=user_id + ) + role_names, permission_keys = await self.user_management_service.get_role_and_permission_by_user_id( + user_id=user_id + ) + if await self.user_auth_service.is_flid_reset_required(user_id): + return ( + UserLoginAction.REVIEW_AND_REVISE_FLID, + user_account.user_role, + user_id, + email.split("@")[0], + preferred_region, + role_names, + permission_keys, + ) + + user_flid = await self.user_auth_service.get_user_flid(user_id) + if await self.user_auth_service.is_password_reset_required(user_id): + return ( + UserLoginAction.NEW_USER_SET_PASSWORD, + user_account.user_role, + user_id, + user_flid, + preferred_region, + role_names, + permission_keys, + ) + return ( + UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED, + user_account.user_role, + user_id, + user_flid, + preferred_region, + role_names, + permission_keys, + ) + else: + await self.module_logger.log_warning( + warning="The auth code is invalid.", + properties={"email": email, "code": code}, + ) + # TODO refactor this to reduce None + return UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None, None, None + + async def signin_with_email_and_password( + self, email: str, password: str + ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str], Optional[List[str]], Optional[List[str]]]: + + # check if the user account exist + user_id = await self.user_auth_service.get_user_id_by_email(email) + + # if it cannot find user account according to the email address, new user + is_new_user = user_id is None + + if is_new_user: + # cannot find the email address + # TODO refactor this to reduce None + return (UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None, None) + else: + if await self.user_auth_service.is_password_reset_required(user_id): + # password hasn't been set before, save password for the user + return ( + UserLoginAction.NEW_USER_SET_PASSWORD, + None, + None, + None, + None, + None + ) + else: + if await self.user_auth_service.verify_user_with_password( + user_id, password + ): + user_account = await self.user_management_service.get_account_by_id( + user_id=user_id + ) + role_names, permission_keys = await self.user_management_service.get_role_and_permission_by_user_id(user_id) + if await self.user_auth_service.is_flid_reset_required(user_id): + return ( + UserLoginAction.REVIEW_AND_REVISE_FLID, + user_account.user_role, + user_id, + email.split("@")[0], + role_names, + permission_keys, + ) + + user_flid = await self.user_auth_service.get_user_flid(user_id) + + # password verification passed + return ( + UserLoginAction.USER_SIGNED_IN, + user_account.user_role, + user_id, + user_flid, + role_names, + permission_keys + ) + else: + # ask user to input password again. + # TODO: we need to limit times of user to input the wrong password + # TODO refactor this to reduce None + return ( + UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED, + None, + None, + None, + None, + None + ) + + async def update_new_user_flid( + self, user_id: str, user_flid: str + ) -> Tuple[UserLoginAction, Optional[str]]: + if await self.user_auth_service.is_flid_available(user_flid): + + code_depot_email = "{}@freeleaps.com".format(user_flid) + result = await self.code_depot_service.create_depot_user( + user_flid, user_id, code_depot_email + ) + + if not result: + await self.module_logger.log_error( + error="Failed to create depot user for {} with flid {} and email {}".format( + user_id, user_flid, code_depot_email + ), + properties={ + "user_id": user_id, + "user_flid": user_flid, + "code_depot_email": code_depot_email, + }, + ) + return ( + UserLoginAction.REVIEW_AND_REVISE_FLID, + "{}{}".format(user_flid, random.randint(100, 999)), + ) + await self.user_auth_service.update_flid(user_id, user_flid) + if await self.user_auth_service.is_password_reset_required(user_id): + return ( + UserLoginAction.NEW_USER_SET_PASSWORD, + user_flid, + ) + else: + return ( + UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED, + user_flid, + ) + else: + return ( + UserLoginAction.REVIEW_AND_REVISE_FLID, + "{}{}".format(user_flid, random.randint(100, 999)), + ) + + async def try_signin_with_email(self, email: str, host: str) -> UserLoginAction: + """try signin through email, generate auth code and send to the email address + + Args: + email (str): email address + host (str): host url that user tried to sign in + + Returns: + int: UserLoginAction + """ + user_id = await self.user_auth_service.get_user_id_by_email(email) + + is_password_reset_required = False + if user_id: + is_password_reset_required = ( + await self.user_auth_service.is_password_reset_required(user_id) + ) + + if user_id is None or is_password_reset_required: + # send auth code through email if the email address + # hasn't been associated with any account. + # Or if the user's password is empty, which means the user's pasword hasn't been set. + + mail_code = await self.user_auth_service.generate_auth_code_for_object( + email, AuthType.EMAIL + ) + await self.notification_service.send_notification( + sender_id=app_settings.SYSTEM_USER_ID, + channels=["2"], # 2 maps to email in NotificationChannel + receiver_id=email, + subject="email", + event="authentication", + properties={"auth_code": mail_code}, + # TODO: reconsider necessity of adding region info here + # region=RegionHandler().detect_from_host(host), + ) + return UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE + else: + return UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED + + async def try_magicleaps_signin_with_email(self, email: str, host: str) -> UserLoginAction: + """try signin through email using MagicLeaps branding, generate auth code and send to the email address + + Args: + email (str): email address + host (str): host url that user tried to sign in + + Returns: + int: UserLoginAction + """ + user_id = await self.user_auth_service.get_user_id_by_email(email) + + is_password_reset_required = False + if user_id: + is_password_reset_required = ( + await self.user_auth_service.is_password_reset_required(user_id) + ) + + if user_id is None or is_password_reset_required: + # send auth code through email if the email address + # hasn't been associated with any account. + # Or if the user's password is empty, which means the user's pasword hasn't been set. + + mail_code = await self.user_auth_service.generate_auth_code_for_object( + email, AuthType.EMAIL + ) + await self.notification_service.send_notification( + sender_id=app_settings.SYSTEM_USER_ID, + channels=["2"], # 2 maps to email in NotificationChannel + receiver_id=email, + subject="email", + event="magicleaps_authentication", # Use the new event type + properties={"auth_code": mail_code}, + # TODO: reconsider necessity of adding region info here + # region=RegionHandler().detect_from_host(host), + ) + return UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE + else: + return UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED + + async def reset_password_through_email(self, email: str, host: str) -> int: + """verify the email is exisitng, clear the existing password, + generate auth code and send to the email address + so in the following steps, the user can reset their password. + + Args: + email (str): email address + host (str): host that user will perform the reset on + + Returns: + int: UserLoginAction + """ + + user_id = await self.user_auth_service.get_user_id_by_email(email) + if user_id is not None: + # send auth code through email if the email address + # hasn been associated with any account. + mail_code = await self.user_auth_service.generate_auth_code_for_object( + email, AuthType.EMAIL + ) + await self.notification_service.send_notification( + sender_id=app_settings.SYSTEM_USER_ID, + channels=["2"], # 2 maps to email in NotificationChannel + receiver_id=email, + subject="email", + event="authentication", + properties={"auth_code": mail_code}, + ) + + await self.user_auth_service.reset_password(user_id) + + return UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE + else: + return UserLoginAction.EMAIL_NOT_ASSOCIATED_WITH_USER + + async def update_user_password(self, user_id: str, password: str) -> dict[str, any]: + error_message = """ + Password does not pass complexity requirements: + - At least one lowercase character + - At least one uppercase character + - At least one digit + - At least one special character (punctuation, brackets, quotes, etc.) + """ + if not check_password_complexity(password): + raise InvalidDataError(error_message) + + user_flid = await self.user_auth_service.get_user_flid(user_id) + await self.user_auth_service.save_password_auth_method( + user_id, user_flid, password + ) + return {"succeeded": True} + + async def update_user_password_no_depot(self, user_id: str, password: str) -> dict[str, any]: + error_message = """ + Password does not pass complexity requirements: + - At least one lowercase character + - At least one uppercase character + - At least one digit + - At least one special character (punctuation, brackets, quotes, etc.) + """ + if not check_password_complexity(password): + raise InvalidDataError(error_message) + + user_flid = await self.user_auth_service.get_user_flid(user_id) + await self.user_auth_service.save_password_auth_method_no_depot( + user_id, user_flid, password + ) + return {"succeeded": True} + + async def send_email_code(self, sender_id: str, email: str) -> bool: + mail_code = await self.user_auth_service.generate_auth_code_for_object( + email, AuthType.EMAIL + ) + success = await self.notification_service.send_notification( + sender_id=sender_id, + channels=["email"], + receiver_id=email, + subject="email", + event="authentication", + properties={"auth_code": mail_code}, + ) + return success + + async def send_mobile_code(self, sender_id: str, mobile: str) -> bool: + mail_code = await self.user_auth_service.generate_auth_code_for_object( + mobile, AuthType.MOBILE + ) + success = await self.notification_service.send_notification( + sender_id=sender_id, + channels=["email"], + receiver_id=mobile, + subject="mobile", + event="authentication", + properties={"auth_code": mail_code}, + ) + return success diff --git a/backend/infra/__init__.py b/backend/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/infra/api_key_introspect_handler.py b/backend/infra/api_key_introspect_handler.py new file mode 100644 index 0000000..eb3fde6 --- /dev/null +++ b/backend/infra/api_key_introspect_handler.py @@ -0,0 +1,52 @@ +from typing import Dict, Any +import httpx +from fastapi import HTTPException +from common.config.app_settings import app_settings +from common.log.log_utils import log_entry_exit_async +from common.log.module_logger import ModuleLogger + + +class ApiKeyIntrospectHandler: + """ + Freeleaps Auth Service API Key Introspect Handle + """ + + def __init__(self) -> None: + self.module_logger = ModuleLogger(sender_id=ApiKeyIntrospectHandler.__name__) + self.auth_service_base = app_settings.AUTH_SERVICE_ENDPOINT + + + @log_entry_exit_async + async def api_key_introspect(self, api_key: str) -> Dict[str, Any]: + """ + Introspect API key by calling external auth service + + Args: + api_key: The API key to introspect + + Returns: + Dictionary containing the API key details + + Raises: + HTTPException: If the external service call fails + """ + api_url = self.auth_service_base + "keys/introspect_api_key" + await self.module_logger.log_info(f"Starting API Key validation for key") + + async with httpx.AsyncClient() as client: + response = await client.post( + api_url, + json={"api_key": api_key} + ) + + if response.status_code != 200: + error_detail = response.json() if response.content else {"error": "Unknown error"} + await self.module_logger.log_error(f"API Key validation failed - Status: {response.status_code}, Error: {error_detail}") + raise HTTPException( + status_code=response.status_code, + detail=error_detail + ) + + validation_result = response.json() + await self.module_logger.log_info(f"API Key validation successful - Active: {validation_result.get('active', False)}") + return validation_result diff --git a/backend/infra/auth/user_auth_handler.py b/backend/infra/auth/user_auth_handler.py new file mode 100644 index 0000000..1d5bdf1 --- /dev/null +++ b/backend/infra/auth/user_auth_handler.py @@ -0,0 +1,355 @@ +import bcrypt +from datetime import datetime, timedelta, timezone +from typing import Optional + +from common.utils.string import generate_auth_code +from backend.services.code_depot.code_depot_service import ( + CodeDepotService, +) +from backend.models.user.constants import ( + AuthType, +) +from backend.models.user.models import ( + AuthCodeDoc, + UserEmailDoc, + UserMobileDoc, + UserPasswordDoc, +) + +from backend.models.user_profile.models import BasicProfileDoc + + +class UserAuthHandler: + def __init__(self) -> None: + self.code_depot_service = CodeDepotService() + + async def verify_user_with_password(self, user_id: str, password: str) -> bool: + """Verify user's password + Args: + user_id (str): user identity, _id in UserAccountDoc + password (str): password user provided, clear text + + Returns: + bool: True if password is correct, else return False + """ + + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password: + # password is reseted to empty string, cannot be verified + if user_password.password == "": + return False + + if bcrypt.checkpw( + password.encode("utf-8"), user_password.password.encode("utf-8") + ): + return True + else: + return False + else: + return False + + async def get_user_password(self, user_id: str) -> Optional[str]: + """Get user password through the user_id + + Args: + user_id (str): user identity, _id in UserAccountDoc + + Returns: + str: password hash + """ + + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password is None: + return None + else: + return user_password.password + + async def get_user_email(self, user_id: str) -> Optional[str]: + """get user email through the user_id + + Args: + user_id (str): user identity, _id in UserAccountDoc + + Returns: + str: email address + """ + user_email = await UserEmailDoc.find( + UserEmailDoc.user_id == user_id + ).first_or_none() + + if user_email is None: + return None + else: + return user_email.email + + async def get_user_id_by_email(self, email: str) -> Optional[str]: + """get user id through email from user_email doc + + Args: + email (str): email address, compare email address in lowercase + + Returns: + Optional[str]: user_id or None + """ + user_email = await UserEmailDoc.find( + UserEmailDoc.email == email.lower() + ).first_or_none() + + if user_email is None: + return None + else: + return user_email.user_id + + def user_sign_out(self, token): + pass + + async def verify_email_code(self, email: str, code: str) -> bool: + """sign in with email and code + + Args: + email (str): email address + code (str): auth code to be verified + + Returns: + bool: True if code is valid, False otherwise + """ + result = await AuthCodeDoc.find( + AuthCodeDoc.method == email.lower(), + AuthCodeDoc.auth_code == code, + AuthCodeDoc.expiry > datetime.now(timezone.utc), + AuthCodeDoc.method_type == AuthType.EMAIL, + ).first_or_none() + + if result: + return True + else: + return False + + async def get_user_mobile(self, user_id: str) -> Optional[str]: + """get user mobile number through the user_id + + Args: + user_id (str): user identity, _id in UserAccountDoc + + Returns: + str: mobile number + """ + user_mobile = await UserMobileDoc.find( + UserMobileDoc.user_id == user_id + ).first_or_none() + + if user_mobile is None: + return None + else: + return user_mobile.mobile + + async def generate_auth_code_for_mobile(self, mobile: str) -> str: + """send auth code to mobile number + + Args: + mobile (str): mobile number + """ + auth_code = generate_auth_code() + expiry = datetime.now(timezone.utc) + timedelta(minutes=5) + auth_code_doc = AuthCodeDoc( + auth_code=auth_code, + method=mobile.lower(), + method_type=AuthType.MOBILE, + expiry=expiry, + ) + + await auth_code_doc.create() + return auth_code + + async def verify_mobile_with_code(self, mobile, code): + """sign in with mobile and code + + Args: + mobile (str): mobile number + code (str): auth code to be verified + + Returns: + bool: True if code is valid, False otherwise + """ + result = await AuthCodeDoc.find( + AuthCodeDoc.method == mobile.lower(), + AuthCodeDoc.auth_code == code, + AuthCodeDoc.expiry > datetime.now(timezone.utc), + AuthCodeDoc.method_type == AuthType.MOBILE, + ).first_or_none() + + if result: + return True + else: + return False + + async def save_email_auth_method(self, user_id: str, email: str): + """save email auth method to user_email doc + + Args: + user_id (str): user id + email (str): email address + """ + user_email = await UserEmailDoc.find( + UserEmailDoc.user_id == user_id + ).first_or_none() + + if user_email is None: + new_user_email = UserEmailDoc(user_id=user_id, email=email.lower()) + await new_user_email.create() + else: + user_email.email = email.lower() + await user_email.save() + + async def save_password_auth_method(self, user_id: str, user_flid, password: str): + """save password auth method to user_password doc + + Args: + user_id (str): user id + password (str): user password + """ + password_hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password is None: + new_user_password = UserPasswordDoc( + user_id=user_id, password=password_hashed + ) + await new_user_password.create() + else: + user_password.password = password_hashed + await user_password.save() + + result = await self.code_depot_service.update_depot_user_password( + user_flid, password + ) + if not result: + raise Exception("Failed to update user password in code depot") + + async def save_password_auth_method_no_depot(self, user_id: str, user_flid, password: str): + """save password auth method to user_password doc without updating depot service + + Args: + user_id (str): user id + password (str): user password + """ + password_hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password is None: + new_user_password = UserPasswordDoc( + user_id=user_id, password=password_hashed + ) + await new_user_password.create() + else: + user_password.password = password_hashed + await user_password.save() + + # Skip depot service call - users don't exist in Gitea, so we don't update depot password + + async def reset_password(self, user_id: str): + """clean password auth method from user_password doc + + Args: + user_id (str): user id + """ + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password: + user_password.password = "" + await user_password.save() + else: + raise Exception("User password was not set before.") + + async def is_password_reset_required(self, user_id: str) -> bool: + """check if password is required for the user + + Args: + user_id (str): user id + + Returns: + bool: True if password is required, False otherwise + """ + user_password = await UserPasswordDoc.find( + UserPasswordDoc.user_id == user_id + ).first_or_none() + + if user_password: + return user_password.password == "" + else: + return True + + async def is_flid_reset_required(self, user_id: str) -> bool: + basic_profile = await BasicProfileDoc.find_one( + BasicProfileDoc.user_id == user_id + ) + + if basic_profile: + return basic_profile.FLID.update_time == basic_profile.FLID.create_time + + async def is_flid_available(self, user_flid: str) -> bool: + basic_profile = await BasicProfileDoc.find_one( + BasicProfileDoc.FLID.identity == user_flid + ) + + if basic_profile: + return False + else: + return True + + async def get_flid(self, user_id: str) -> str: + basic_profile = await BasicProfileDoc.find_one( + BasicProfileDoc.user_id == user_id + ) + + if basic_profile: + return basic_profile.FLID.identity + else: + return None + + async def update_flid(self, user_id: str, flid: str) -> bool: + basic_profile = await BasicProfileDoc.find_one( + BasicProfileDoc.user_id == user_id + ) + + if basic_profile: + basic_profile.FLID.identity = flid + basic_profile.FLID.update_time = datetime.now(timezone.utc) + basic_profile.FLID.set_by = user_id + await basic_profile.save() + return True + else: + return False + + async def generate_auth_code(self, deliver_object: str, auth_type: AuthType) -> str: + """send auth code to email address + + Args: + deliver_object (str): email address, mobile, etc + auth_type (str): authentication type + """ + auth_code = generate_auth_code() + expiry = datetime.now(timezone.utc) + timedelta(minutes=5) + auth_code_doc = AuthCodeDoc( + auth_code=auth_code, + method=deliver_object.lower(), + method_type=auth_type, + expiry=expiry, + ) + + await auth_code_doc.create() + return auth_code diff --git a/backend/infra/permission/__init__.py b/backend/infra/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/infra/permission/permission_handler.py b/backend/infra/permission/permission_handler.py new file mode 100644 index 0000000..57f41ec --- /dev/null +++ b/backend/infra/permission/permission_handler.py @@ -0,0 +1,179 @@ +from typing import Optional, List, Tuple + +from fastapi.exceptions import RequestValidationError + +from backend.models.permission.models import PermissionDoc, RoleDoc +from bson import ObjectId +from datetime import datetime + + +class PermissionHandler: + def __init__(self): + pass + + async def create_permission(self, permission_key: str, permission_name: str, + description: Optional[str] = None) -> Optional[PermissionDoc]: + """Create a new permission document""" + if not permission_key or not permission_name: + raise RequestValidationError("permission_key and permission_name are required.") + # if exists. + if await PermissionDoc.find_one( + {str(PermissionDoc.permission_key): permission_key}) or await PermissionDoc.find_one( + {str(PermissionDoc.permission_name): permission_name}): + raise RequestValidationError("permission has already been created.") + doc = PermissionDoc( + permission_key=permission_key, + permission_name=permission_name, + description=description, + created_at=datetime.now(), + updated_at=datetime.now() + ) + await doc.create() + return doc + + async def update_permission(self, permission_id: str, permission_key: Optional[str] = None, + permission_name: Optional[str] = None, description: Optional[str] = None) -> Optional[ + PermissionDoc]: + """Update an existing permission document by id, ensuring permission_key is unique""" + if not permission_id or not permission_key or not permission_name: + raise RequestValidationError("permission_id, permission_key and permission_name is required.") + doc = await PermissionDoc.get(permission_id) + if not doc: + raise RequestValidationError("Permission not found.") + if doc.is_default: + raise RequestValidationError("Default permission cannot be updated.") + # Check for uniqueness (exclude self) + conflict = await PermissionDoc.find_one({ + "$and": [ + {"_id": {"$ne": permission_id}}, + {"$or": [ + {str(PermissionDoc.permission_key): permission_key}, + {str(PermissionDoc.permission_name): permission_name} + ]} + ] + }) + if conflict: + raise RequestValidationError("Permission name or permission key already exists.") + doc.permission_key = permission_key + doc.permission_name = permission_name + doc.description = description + doc.updated_at = datetime.now() + + await doc.save() + return doc + + async def create_or_update_permission(self, permission_key: str, permission_name: str, custom_permission_id: Optional[str], description: Optional[str] = None) -> Optional[PermissionDoc]: + """Create or update a permission document""" + # Input validation + if not permission_key or not permission_name: + raise RequestValidationError("permission_key and permission_name are required.") + + def create_new_doc(): + return PermissionDoc( + permission_key=permission_key, + permission_name=permission_name, + description=description, + created_at=datetime.now(), + updated_at=datetime.now() + ) + + def update_doc_fields(doc): + doc.permission_key = permission_key + doc.permission_name = permission_name + doc.description = description + doc.updated_at = datetime.now() + + try: + # Check if permission with this key already exists + existing_doc = await PermissionDoc.find_one( + {str(PermissionDoc.permission_key): permission_key} + ) + except Exception as e: + existing_doc = None + + if existing_doc: + # If permission with this key already exists + if custom_permission_id and str(custom_permission_id) != str(existing_doc.id): + # Different ID provided - replace the document + id_conflict = await PermissionDoc.get(custom_permission_id) + if id_conflict: + raise RequestValidationError("Permission with the provided ID already exists.") + + new_doc = create_new_doc() + new_doc.id = custom_permission_id + await new_doc.create() + await existing_doc.delete() + return new_doc + else: + # Same ID or no ID provided - update existing document + update_doc_fields(existing_doc) + await existing_doc.save() + return existing_doc + else: + # If no existing document with this key, create new document + new_doc = create_new_doc() + + if custom_permission_id: + id_conflict = await PermissionDoc.get(custom_permission_id) + if id_conflict: + raise RequestValidationError("Permission with the provided ID already exists.") + new_doc.id = custom_permission_id + + await new_doc.create() + return new_doc + + async def query_permissions( + self, + permission_key: Optional[str] = None, + permission_name: Optional[str] = None, + skip: int = 0, + limit: int = 10 + ) -> Tuple[List[PermissionDoc], int]: + """Query permissions with pagination and fuzzy search""" + query = {} + if permission_key: + query["permission_key"] = {"$regex": permission_key, "$options": "i"} + if permission_name: + query["permission_name"] = {"$regex": permission_name, "$options": "i"} + cursor = PermissionDoc.find(query) + total = await cursor.count() + docs = await cursor.skip(skip).limit(limit).to_list() + return docs, total + + async def query_permissions_no_pagination( + self, + permission_id: Optional[str] = None, + permission_key: Optional[str] = None, + permission_name: Optional[str] = None + ) -> Tuple[List[PermissionDoc], int]: + """Query permissions fuzzy search""" + query = {} + if permission_id: + try: + query["_id"] = ObjectId(permission_id) # Convert string to ObjectId for MongoDB + except Exception: + raise RequestValidationError("Invalid permission_id format. Must be a valid ObjectId.") + if permission_key: + query["permission_key"] = {"$regex": permission_key, "$options": "i"} + if permission_name: + query["permission_name"] = {"$regex": permission_name, "$options": "i"} + cursor = PermissionDoc.find(query) + total = await cursor.count() + docs = await cursor.to_list() + return docs, total + + async def delete_permission(self, permission_id: str) -> None: + """Delete a permission document after checking if it is referenced by any role and is not default""" + if not permission_id: + raise RequestValidationError("permission_id is required.") + # Check if any role references this permission + role = await RoleDoc.find_one({"permission_ids": str(permission_id)}) + if role: + raise RequestValidationError("Permission is referenced by a role and cannot be deleted.") + doc = await PermissionDoc.get(permission_id) + if not doc: + raise RequestValidationError("Permission not found.") + # Check if the permission is default + if doc.is_default: + raise RequestValidationError("Default permission cannot be deleted.") + await doc.delete() diff --git a/backend/infra/permission/role_handler.py b/backend/infra/permission/role_handler.py new file mode 100644 index 0000000..08d5d83 --- /dev/null +++ b/backend/infra/permission/role_handler.py @@ -0,0 +1,195 @@ +from typing import Optional, List, Tuple + +from fastapi.exceptions import RequestValidationError + +from backend.models.permission.models import RoleDoc, PermissionDoc, UserRoleDoc +from bson import ObjectId +from datetime import datetime + + +class RoleHandler: + def __init__(self): + pass + + async def create_role(self, role_key: str, role_name: str, role_description: Optional[str], role_level: int) -> Optional[RoleDoc]: + """Create a new role, ensuring role_key and role_name are unique and not empty""" + if not role_key or not role_name: + raise RequestValidationError("role_key and role_name are required.") + if await RoleDoc.find_one({str(RoleDoc.role_key): role_key}) or await RoleDoc.find_one( + {str(RoleDoc.role_name): role_name}): + raise RequestValidationError("role_key or role_name has already been created.") + doc = RoleDoc( + role_key=role_key, + role_name=role_name, + role_description=role_description, + permission_ids=[], + role_level=role_level, + created_at=datetime.now(), + updated_at=datetime.now() + ) + await doc.create() + return doc + + async def update_role(self, role_id: str, role_key: str, role_name: str, + role_description: Optional[str], role_level: int) -> Optional[ + RoleDoc]: + """Update an existing role, ensuring role_key and role_name are unique and not empty""" + if not role_id or not role_key or not role_name: + raise RequestValidationError("role_id, role_key and role_name are required.") + doc = await RoleDoc.get(role_id) + if not doc: + raise RequestValidationError("role not found.") + if doc.is_default: + raise RequestValidationError("Default role cannot be updated.") + # Check for uniqueness (exclude self) + conflict = await RoleDoc.find_one({ + "$and": [ + {"_id": {"$ne": role_id}}, + {"$or": [ + {str(RoleDoc.role_key): role_key}, + {str(RoleDoc.role_name): role_name} + ]} + ] + }) + if conflict: + raise RequestValidationError("role_key or role_name already exists.") + doc.role_key = role_key + doc.role_name = role_name + doc.role_description = role_description + doc.role_level = role_level + doc.updated_at = datetime.now() + await doc.save() + return doc + + async def create_or_update_role(self, role_key: str, role_name: str, role_level: int, custom_role_id: Optional[str], role_description: Optional[str] = None) -> Optional[RoleDoc]: + """Create or update a role document""" + # Input validation + if not role_key or not role_name: + raise RequestValidationError("role_key and role_name are required.") + + def create_new_doc(): + return RoleDoc( + role_key=role_key, + role_name=role_name, + role_description=role_description, + role_level=role_level, + permission_ids=[], + created_at=datetime.now(), + updated_at=datetime.now() + ) + def update_doc_fields(doc): + doc.role_key = role_key + doc.role_name = role_name + doc.role_description = role_description + doc.role_level = role_level + doc.updated_at = datetime.now() + + # Check if role with this key already exists + existing_doc = await RoleDoc.find_one( + {str(RoleDoc.role_key): role_key} + ) + + if existing_doc: + # If role with this key already exists + if custom_role_id and str(custom_role_id) != str(existing_doc.id): + # Different ID provided - replace the document + id_conflict = await RoleDoc.get(custom_role_id) + if id_conflict: + raise RequestValidationError("Role with the provided ID already exists.") + + new_doc = create_new_doc() + new_doc.id = custom_role_id + await new_doc.create() + await existing_doc.delete() + return new_doc + + else: + # Same ID or no ID provided - update existing document + update_doc_fields(existing_doc) + await existing_doc.save() + return existing_doc + else: + # If no existing document with this key, create new document + new_doc = create_new_doc() + + if custom_role_id: + id_conflict = await RoleDoc.get(custom_role_id) + if id_conflict: + raise RequestValidationError("Role with the provided ID already exists.") + new_doc.id = custom_role_id + + await new_doc.insert() + return new_doc + + async def query_roles(self, role_key: Optional[str], role_name: Optional[str], skip: int = 0, limit: int = 10) -> \ + Tuple[List[RoleDoc], int]: + """Query roles with pagination and fuzzy search by role_key and role_name""" + query = {} + if role_key: + query["role_key"] = {"$regex": role_key, "$options": "i"} + if role_name: + query["role_name"] = {"$regex": role_name, "$options": "i"} + cursor = RoleDoc.find(query) + total = await cursor.count() + docs = await cursor.skip(skip).limit(limit).to_list() + return docs, total + + async def query_roles_no_pagination( + self, + role_id: Optional[str] = None, + role_key: Optional[str] = None, + role_name: Optional[str] = None + ) -> Tuple[List[RoleDoc], int]: + """Query roles fuzzy search without pagination""" + query = {} + if role_id: + try: + query["_id"] = ObjectId(role_id) # Convert string to ObjectId for MongoDB + except Exception: + raise RequestValidationError("Invalid role_id format. Must be a valid ObjectId.") + if role_key: + query["role_key"] = {"$regex": role_key, "$options": "i"} + if role_name: + query["role_name"] = {"$regex": role_name, "$options": "i"} + cursor = RoleDoc.find(query) + total = await cursor.count() + docs = await cursor.to_list() + return docs, total + + async def assign_permissions_to_role(self, role_id: str, permission_ids: List[str]) -> Optional[RoleDoc]: + """Assign permissions to a role by updating the permission_ids field""" + if not role_id or not permission_ids: + raise RequestValidationError("role_id and permission_ids are required.") + doc = await RoleDoc.get(role_id) + if not doc: + raise RequestValidationError("Role not found.") + + # Validate that all permission_ids exist in the permission collection + for permission_id in permission_ids: + permission_doc = await PermissionDoc.get(permission_id) + if not permission_doc: + raise RequestValidationError(f"Permission with id {permission_id} not found.") + + # Remove duplicates from permission_ids + unique_permission_ids = list(dict.fromkeys(permission_ids)) + + doc.permission_ids = unique_permission_ids + doc.updated_at = datetime.now() + await doc.save() + return doc + + async def delete_role(self, role_id: str) -> None: + """Delete a role document after checking if it is referenced by any user and is not default""" + if not role_id: + raise RequestValidationError("role_id is required.") + # Check if any user references this role + user_role = await UserRoleDoc.find_one({"role_ids": str(role_id)}) + if user_role: + raise RequestValidationError("Role is referenced by a user and cannot be deleted.") + doc = await RoleDoc.get(role_id) + if not doc: + raise RequestValidationError("Role not found.") + # Check if the role is default + if doc.is_default: + raise RequestValidationError("Default role cannot be deleted.") + await doc.delete() diff --git a/backend/infra/permission/user_role_handler.py b/backend/infra/permission/user_role_handler.py new file mode 100644 index 0000000..2b02fe8 --- /dev/null +++ b/backend/infra/permission/user_role_handler.py @@ -0,0 +1,65 @@ +from typing import Optional, List +from fastapi.exceptions import RequestValidationError +from backend.models.permission.models import RoleDoc, UserRoleDoc, PermissionDoc +from bson import ObjectId + + +class UserRoleHandler: + def __init__(self): + pass + + async def assign_roles_to_user(self, user_id: str, role_ids: List[str]) -> Optional[UserRoleDoc]: + """Assign roles to a user by updating or creating the UserRoleDoc""" + if not user_id or not role_ids: + raise RequestValidationError("user_id and role_ids are required.") + + # Validate that all role_ids exist in the role collection + for role_id in role_ids: + role_doc = await RoleDoc.get(role_id) + if not role_doc: + raise RequestValidationError(f"Role with id {role_id} not found.") + + # Remove duplicates from role_ids + unique_role_ids = list(dict.fromkeys(role_ids)) + + # Check if UserRoleDoc already exists for this user + existing_user_role = await UserRoleDoc.find_one(UserRoleDoc.user_id == user_id) + + if existing_user_role: + # Update existing UserRoleDoc + existing_user_role.role_ids = unique_role_ids + await existing_user_role.save() + return existing_user_role + else: + # Create new UserRoleDoc + user_role_doc = UserRoleDoc( + user_id=user_id, + role_ids=unique_role_ids + ) + await user_role_doc.insert() + return user_role_doc + + async def get_role_and_permission_by_user_id(self, user_id: str) -> tuple[list[str], list[str]]: + """Get all role names and permission keys for a user by user_id""" + # Query user roles + user_role_doc = await UserRoleDoc.find_one(UserRoleDoc.user_id == user_id) + if not user_role_doc or not user_role_doc.role_ids: + # No roles assigned + return [], [] + # Query all roles by role_ids + roles = await RoleDoc.find({"_id": {"$in": user_role_doc.role_ids}}).to_list() + role_names = [role.role_name for role in roles] + # Collect all permission_ids from all roles + all_permission_ids = [] + for role in roles: + if role.permission_ids: + all_permission_ids.extend(role.permission_ids) + # Remove duplicates + unique_permission_ids = list(dict.fromkeys(all_permission_ids)) + # Query all permissions by permission_ids + if unique_permission_ids: + permissions = await PermissionDoc.find({"_id": {"$in": unique_permission_ids}}).to_list() + permission_keys = [perm.permission_key for perm in permissions] + else: + permission_keys = [] + return role_names, permission_keys diff --git a/backend/infra/user_profile/user_profile_handler.py b/backend/infra/user_profile/user_profile_handler.py new file mode 100644 index 0000000..5a79fb7 --- /dev/null +++ b/backend/infra/user_profile/user_profile_handler.py @@ -0,0 +1,121 @@ +from common.constants.region import UserRegion +from datetime import datetime, timedelta, timezone +from backend.models.user.models import UserAccountDoc +from backend.models.user.constants import ( + UserAccountProperty, +) +from backend.models.permission.constants import ( + AdministrativeRole, + Capability, +) +from typing import Optional +from backend.models.user_profile.models import ( + SelfIntro, + Tags, + Photo, + Email, + Mobile, + FLID, + Password, + BasicProfileDoc, + ProviderProfileDoc, + ExpectedSalary, +) + +from backend.models.user.constants import UserRegionToCurrency + + +class UserProfileHandler: + async def create_new_user_account( + self, + property: UserAccountProperty, + capability: Capability, + user_role: AdministrativeRole, + region: UserRegion, + ) -> UserAccountDoc: + user_account = UserAccountDoc( + profile_id=None, + account_id=None, + service_plan_id=None, + properties=int(property), + capabilities=int(capability), + user_role=int(user_role), + region=region, + ) + return await user_account.create() + + async def create_basic_profile( + self, + user_id: str, + email_address: str, + email_verified: bool, + mobile_number: str, + mobile_verified: bool, + password_setup: bool, + region: UserRegion, + time_zone: Optional[str] = "UTC", + ) -> BasicProfileDoc: + basic_profile = await BasicProfileDoc.find_one( + BasicProfileDoc.user_id == user_id + ) + if basic_profile: + return basic_profile + else: + tags = Tags(skill=[]) + self_intro = SelfIntro(summary="", content_html="", tags=tags) + photo = Photo(url="", base64="", filename="") + email = Email(address=email_address, verified=email_verified) + mobile = Mobile(number=mobile_number, verified=mobile_verified) + current_time = datetime.now(timezone.utc) + flid = FLID( + identity=user_id, + set_by=user_id, + create_time=current_time, + update_time=current_time, + ) + password = Password( + set_up=password_setup, + update_time=current_time, + expiry=(current_time + timedelta(days=365)), + ) + basic_profile = BasicProfileDoc( + user_id=user_id, + self_intro=self_intro, + photo=photo, + email=email, + mobile=mobile, + FLID=flid, + password=password, + region=region, + time_zone=time_zone, + ) + new_basic_profile = await basic_profile.create() + return new_basic_profile + + async def create_provider_profile(self, user_id: str) -> ProviderProfileDoc: + provider_profile = await ProviderProfileDoc.find_one( + {"user_id": user_id} + ) + if provider_profile: + return provider_profile + else: + region = await self.__get_user_region(user_id) + expected_salary = ExpectedSalary( + currency=UserRegionToCurrency[region], hourly=0.0 + ) + provider_profile = ProviderProfileDoc( + user_id=user_id, + expected_salary=expected_salary, + accepting_request=False, + ) + new_provider_profile = await provider_profile.create() + return new_provider_profile + + async def get_account_by_id(self, user_id: str) -> UserAccountDoc: + return await UserAccountDoc.get(user_id) + + async def __get_user_region(self, user_id: str) -> UserRegion: + user_profile = await BasicProfileDoc.find_one( + BasicProfileDoc.user_id == user_id + ) + return user_profile.region if user_profile else UserRegion.OTHER diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..4cf8898 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,8 @@ +from .user import user_models +from .user_profile import profile_models +from .permission import permission_models + +backend_models = [] +backend_models.extend(user_models) +backend_models.extend(profile_models) +backend_models.extend(permission_models) diff --git a/backend/models/base_doc.py b/backend/models/base_doc.py new file mode 100644 index 0000000..77fca47 --- /dev/null +++ b/backend/models/base_doc.py @@ -0,0 +1,415 @@ +""" +BaseDoc - A custom document class that provides Beanie-like interface using direct MongoDB operations +""" +import asyncio +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any, Type, Union +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase +from pydantic import BaseModel +from pydantic._internal._model_construction import ModelMetaclass +from common.config.app_settings import app_settings + + +class QueryExpression: + """Query expression for field comparisons""" + def __init__(self, field_name: str): + self.field_name = field_name + + def __eq__(self, other: Any) -> Dict[str, Any]: + """Handle field == value comparisons""" + return {self.field_name: other} + + def __ne__(self, other: Any) -> Dict[str, Any]: + """Handle field != value comparisons""" + return {self.field_name: {"$ne": other}} + + def __gt__(self, other: Any) -> Dict[str, Any]: + """Handle field > value comparisons""" + return {self.field_name: {"$gt": other}} + + def __lt__(self, other: Any) -> Dict[str, Any]: + """Handle field < value comparisons""" + return {self.field_name: {"$lt": other}} + + def __ge__(self, other: Any) -> Dict[str, Any]: + """Handle field >= value comparisons""" + return {self.field_name: {"$gte": other}} + + def __le__(self, other: Any) -> Dict[str, Any]: + """Handle field <= value comparisons""" + return {self.field_name: {"$lte": other}} + + +class FieldDescriptor: + """Descriptor for field access like Beanie's field == value pattern""" + def __init__(self, field_name: str, field_type: type): + self.field_name = field_name + self.field_type = field_type + + def __get__(self, instance: Any, owner: type) -> Any: + """ + - Class access (instance is None): return QueryExpression for building queries + - Instance access (instance is not None): return the actual field value + """ + if instance is None: + return QueryExpression(self.field_name) + return instance.__dict__.get(self.field_name) + + def __set__(self, instance: Any, value: Any) -> None: + """Set instance field value with type validation (compatible with Pydantic validation)""" + if not isinstance(value, self.field_type): + raise TypeError(f"Field {self.field_name} must be {self.field_type}") + instance.__dict__[self.field_name] = value + + +class FieldCondition: + """Represents a field condition for MongoDB queries""" + def __init__(self, field_name: str, value: Any, operator: str = "$eq"): + self.field_name = field_name + self.value = value + self.operator = operator + self.left = self # For compatibility with existing condition parsing + self.right = value + + +# Module-level variables for database connection +_db: Optional[AsyncIOMotorDatabase] = None +_client: Optional[AsyncIOMotorClient] = None + +# Context variable for tenant database +import contextvars +_tenant_db_context: contextvars.ContextVar[Optional[AsyncIOMotorDatabase]] = contextvars.ContextVar('tenant_db', default=None) + + +class QueryModelMeta(ModelMetaclass): + """Metaclass: automatically create FieldDescriptor for model fields""" + def __new__(cls, name: str, bases: tuple, namespace: dict): + # Get model field annotations (like name: str -> "name" and str) + annotations = namespace.get("__annotations__", {}) + + # Create the class first using Pydantic's metaclass + new_class = super().__new__(cls, name, bases, namespace) + + # After Pydantic processes the fields, add the descriptors as class attributes + for field_name, field_type in annotations.items(): + if field_name != 'id': # Skip the id field as it's handled specially + # Add the descriptor as a class attribute + setattr(new_class, field_name, FieldDescriptor(field_name, field_type)) + + return new_class + + def __getattr__(cls, name: str): + """Handle field access like Doc.field_name for query building""" + # Check if this is a field that exists in the model + if hasattr(cls, 'model_fields') and name in cls.model_fields: + return QueryExpression(name) + raise AttributeError(f"'{cls.__name__}' object has no attribute '{name}'") + +class BaseDoc(BaseModel, metaclass=QueryModelMeta): + """ + Base document class that provides Beanie-like interface using direct MongoDB operations. + All model classes should inherit from this instead of Beanie's Document. + """ + + id: Optional[str] = None # MongoDB _id field + + def model_dump(self, **kwargs): + """Override model_dump to exclude field descriptors""" + # Get the default model_dump result + result = super().model_dump(**kwargs) + + # Remove any field descriptors that might have been included + filtered_result = {} + for key, value in result.items(): + if not isinstance(value, FieldDescriptor): + filtered_result[key] = value + + return filtered_result + + @classmethod + def field(cls, field_name: str) -> QueryExpression: + """Get a field expression for query building""" + return QueryExpression(field_name) + + @classmethod + async def _get_database(cls) -> AsyncIOMotorDatabase: + """Get database connection using pure AsyncIOMotorClient""" + # Try to get tenant database from context first + tenant_db = _tenant_db_context.get() + if tenant_db is not None: + return tenant_db + + # Fallback to global database connection + global _db, _client + if _db is None: + _client = AsyncIOMotorClient(app_settings.MONGODB_URI) + _db = _client[app_settings.MONGODB_NAME] + return _db + + @classmethod + def set_tenant_database(cls, db: AsyncIOMotorDatabase): + """Set the tenant database for this context""" + _tenant_db_context.set(db) + + @classmethod + def _get_collection_name(cls) -> str: + """Get collection name from Settings or class name""" + if hasattr(cls, 'Settings') and hasattr(cls.Settings, 'name'): + return cls.Settings.name + else: + # Convert class name to snake_case for collection name + import re + name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', cls.__name__) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower() + + @classmethod + def find(cls, *conditions) -> 'QueryBuilder': + """Find documents matching conditions - returns QueryBuilder for chaining""" + return QueryBuilder(cls, conditions) + + @classmethod + async def find_one(cls, *conditions) -> Optional['BaseDoc']: + """Find one document matching conditions""" + db = await cls._get_database() + collection_name = cls._get_collection_name() + collection = db[collection_name] + + # Convert Beanie-style conditions to MongoDB query + query = cls._convert_conditions_to_query(conditions) + + doc = await collection.find_one(query) + if doc: + # Extract MongoDB _id and convert to string + mongo_id = doc.pop('_id', None) + # Filter doc to only include fields defined in the model + model_fields = set(cls.model_fields.keys()) + filtered_doc = {k: v for k, v in doc.items() if k in model_fields} + # Add the id field + if mongo_id: + filtered_doc['id'] = str(mongo_id) + return cls(**filtered_doc) + return None + + @classmethod + async def get(cls, doc_id: str) -> Optional['BaseDoc']: + """Get document by ID""" + from bson import ObjectId + try: + object_id = ObjectId(doc_id) + except: + return None + + db = await cls._get_database() + collection_name = cls._get_collection_name() + collection = db[collection_name] + + doc = await collection.find_one({"_id": object_id}) + if doc: + # Extract MongoDB _id and convert to string + mongo_id = doc.pop('_id', None) + # Filter doc to only include fields defined in the model + model_fields = set(cls.model_fields.keys()) + filtered_doc = {k: v for k, v in doc.items() if k in model_fields} + # Add the id field + if mongo_id: + filtered_doc['id'] = str(mongo_id) + return cls(**filtered_doc) + return None + + @classmethod + def _convert_conditions_to_query(cls, conditions) -> Dict[str, Any]: + """Convert Beanie-style conditions to MongoDB query""" + if not conditions: + return {} + + query = {} + for condition in conditions: + if isinstance(condition, dict): + # Handle QueryExpression results (dictionaries) and direct dictionary queries + query.update(condition) + elif isinstance(condition, FieldCondition): + # Handle legacy FieldCondition objects + if condition.operator == "$eq": + query[condition.field_name] = condition.value + else: + query[condition.field_name] = {condition.operator: condition.value} + elif hasattr(condition, 'left') and hasattr(condition, 'right'): + # Handle field == value conditions + field_name = condition.left.name + value = condition.right + query[field_name] = value + elif hasattr(condition, '__dict__'): + # Handle complex conditions like FLID.identity == value + if hasattr(condition, 'left') and hasattr(condition, 'right'): + left = condition.left + if hasattr(left, 'name') and hasattr(left, 'left'): + # Nested field access like FLID.identity + field_name = f"{left.left.name}.{left.name}" + value = condition.right + query[field_name] = value + else: + field_name = left.name + value = condition.right + query[field_name] = value + + return query + + def _convert_decimals_to_float(self, obj): + """Convert Decimal objects to float for MongoDB compatibility""" + from decimal import Decimal + + if isinstance(obj, Decimal): + return float(obj) + elif isinstance(obj, dict): + return {key: self._convert_decimals_to_float(value) for key, value in obj.items()} + elif isinstance(obj, list): + return [self._convert_decimals_to_float(item) for item in obj] + else: + return obj + + async def create(self) -> 'BaseDoc': + """Create this document in the database""" + db = await self._get_database() + collection_name = self._get_collection_name() + collection = db[collection_name] + + # Convert to dict and insert, excluding field descriptors + doc_dict = self.model_dump(exclude={'id'}) + + # Convert Decimal objects to float for MongoDB compatibility + doc_dict = self._convert_decimals_to_float(doc_dict) + + result = await collection.insert_one(doc_dict) + + # Set the id field from the inserted document + if result.inserted_id: + self.id = str(result.inserted_id) + + # Return the created document + return self + + async def save(self) -> 'BaseDoc': + """Save this document to the database (update if exists, create if not)""" + db = await self._get_database() + collection_name = self._get_collection_name() + collection = db[collection_name] + + # Convert to dict, excluding field descriptors + doc_dict = self.model_dump(exclude={'id'}) + + # Convert Decimal objects to float for MongoDB compatibility + doc_dict = self._convert_decimals_to_float(doc_dict) + + # Try to find existing document by user_id or other unique fields + query = {} + if hasattr(self, 'user_id'): + query['user_id'] = self.user_id + elif hasattr(self, 'email'): + query['email'] = self.email + elif hasattr(self, 'mobile'): + query['mobile'] = self.mobile + elif hasattr(self, 'auth_code'): + query['auth_code'] = self.auth_code + + if query: + # Update existing document + result = await collection.update_one(query, {"$set": doc_dict}, upsert=True) + # If it was an insert, set the id field + if result.upserted_id: + self.id = str(result.upserted_id) + else: + # Insert new document + result = await collection.insert_one(doc_dict) + if result.inserted_id: + self.id = str(result.inserted_id) + + return self + + async def delete(self) -> bool: + """Delete this document from the database""" + db = await self._get_database() + collection_name = self._get_collection_name() + collection = db[collection_name] + + # Try to find existing document by user_id or other unique fields + query = {} + if hasattr(self, 'user_id'): + query['user_id'] = self.user_id + elif hasattr(self, 'email'): + query['email'] = self.email + elif hasattr(self, 'mobile'): + query['mobile'] = self.mobile + elif hasattr(self, 'auth_code'): + query['auth_code'] = self.auth_code + + if query: + result = await collection.delete_one(query) + return result.deleted_count > 0 + return False + + +class QueryBuilder: + """Query builder for chaining operations like Beanie's QueryBuilder""" + + def __init__(self, model_class: Type[BaseDoc], conditions: tuple): + self.model_class = model_class + self.conditions = conditions + self._limit_value: Optional[int] = None + self._skip_value: Optional[int] = None + + def limit(self, n: int) -> 'QueryBuilder': + """Limit number of results""" + self._limit_value = n + return self + + def skip(self, n: int) -> 'QueryBuilder': + """Skip number of results""" + self._skip_value = n + return self + + async def to_list(self) -> List[BaseDoc]: + """Convert query to list of documents""" + db = await self.model_class._get_database() + collection_name = self.model_class._get_collection_name() + collection = db[collection_name] + + # Convert conditions to MongoDB query + query = self.model_class._convert_conditions_to_query(self.conditions) + + # Build cursor + cursor = collection.find(query) + + if self._skip_value: + cursor = cursor.skip(self._skip_value) + if self._limit_value: + cursor = cursor.limit(self._limit_value) + + # Execute query and convert to model instances + docs = await cursor.to_list(length=None) + results = [] + for doc in docs: + # Extract MongoDB _id and convert to string + mongo_id = doc.pop('_id', None) + # Filter doc to only include fields defined in the model + model_fields = set(self.model_class.model_fields.keys()) + filtered_doc = {k: v for k, v in doc.items() if k in model_fields} + # Add the id field + if mongo_id: + filtered_doc['id'] = str(mongo_id) + results.append(self.model_class(**filtered_doc)) + + return results + + async def first_or_none(self) -> Optional[BaseDoc]: + """Get first result or None""" + results = await self.limit(1).to_list() + return results[0] if results else None + + async def count(self) -> int: + """Count number of matching documents""" + db = await self.model_class._get_database() + collection_name = self.model_class._get_collection_name() + collection = db[collection_name] + + query = self.model_class._convert_conditions_to_query(self.conditions) + return await collection.count_documents(query) diff --git a/backend/models/permission/__init__.py b/backend/models/permission/__init__.py new file mode 100644 index 0000000..cf41ebd --- /dev/null +++ b/backend/models/permission/__init__.py @@ -0,0 +1,3 @@ +from .models import PermissionDoc, RoleDoc, UserRoleDoc + +permission_models = [PermissionDoc, RoleDoc, UserRoleDoc] diff --git a/backend/models/permission/constants.py b/backend/models/permission/constants.py new file mode 100644 index 0000000..89c6104 --- /dev/null +++ b/backend/models/permission/constants.py @@ -0,0 +1,26 @@ +from enum import IntEnum + + +class AdministrativeRole(IntEnum): + NONE = 0 + PERSONAL = 1 + BUSINESS = 2 + CONTRIBUTOR = 4 + ADMINISTRATOR = 8 + # now UI cannot siginin if user role is 8 + + +class Capability(IntEnum): + VISITOR = 1 + COMMUNICATOR = 2 + REQUESTER = 4 + PROVIDER = 8 + DEVELOPER = 16 + + +class Feature(IntEnum): + ANY = 0xFFFFFFFF + SENDMESSAGE = 0x1 + INITIATEREQUEST = 0x2 + MAKEPROPOSAL = 0x4 + CREATEPROJECT = 0x8 diff --git a/backend/models/permission/models.py b/backend/models/permission/models.py new file mode 100644 index 0000000..a55cdcf --- /dev/null +++ b/backend/models/permission/models.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import Optional, List +from ..base_doc import BaseDoc + + +class PermissionDoc(BaseDoc): + permission_name: str + permission_key: str + description: Optional[str] = None # Description of the permission, optional + created_at: datetime = datetime.now() # Creation timestamp, auto-generated + updated_at: datetime = datetime.now() # Last update timestamp, auto-updated + is_default: bool = False + + class Settings: + # Default collections created by Freeleaps for tenant databases use '_' prefix + # to prevent naming conflicts with tenant-created collections + name = "_permission" + indexes = [ + "permission_key" + ] + + +class RoleDoc(BaseDoc): + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: list[str] + role_level: int + revision_id: Optional[str] = None # Revision ID for version control + created_at: datetime = datetime.now() # Creation timestamp, auto-generated + updated_at: datetime = datetime.now() # Last update timestamp, auto-updated + is_default: bool = False + + class Settings: + # Default collections created by Freeleaps for tenant databases use '_' prefix + # to prevent naming conflicts with tenant-created collections + name = "_role" + indexes = [ + "role_level" + ] + +class UserRoleDoc(BaseDoc): + """User role doc""" + user_id: str + role_ids: Optional[List[str]] + + class Settings: + # Default collections created by Freeleaps for tenant databases use '_' prefix + # to prevent naming conflicts with tenant-created collections + name = "_user_role" + indexes = [ + "user_id" + ] \ No newline at end of file diff --git a/backend/models/user/__init__.py b/backend/models/user/__init__.py new file mode 100644 index 0000000..3ab3b72 --- /dev/null +++ b/backend/models/user/__init__.py @@ -0,0 +1,17 @@ +from .models import ( + UserAccountDoc, + UserPasswordDoc, + UserEmailDoc, + UserMobileDoc, + AuthCodeDoc, + UsageLogDoc, +) + +user_models = [ + UserAccountDoc, + UserPasswordDoc, + UserEmailDoc, + UserMobileDoc, + AuthCodeDoc, + UsageLogDoc, +] diff --git a/backend/models/user/constants.py b/backend/models/user/constants.py new file mode 100644 index 0000000..031c7d1 --- /dev/null +++ b/backend/models/user/constants.py @@ -0,0 +1,79 @@ +from enum import IntEnum +from common.constants.region import UserRegion + + +class NewUserMethod(IntEnum): + EMAIL = 1 + MOBILE = 2 + + +class UserAccountProperty(IntEnum): + EMAIL_VERIFIED = 1 + MOBILE_VERIFIED = 2 + PAYMENT_SETUP = 4 + ACCEPT_REQUEST = 8 + READY_PROVIDER = 16 + MANAGE_PROJECT = 32 + + +class UserLoginAction(IntEnum): + VERIFY_EMAIL_WITH_AUTH_CODE = 0 + EXISTING_USER_PASSWORD_REQUIRED = 1 + NEW_USER_SET_PASSWORD = 2 + EMAIL_NOT_ASSOCIATED_WITH_USER = 3 + REVIEW_AND_REVISE_FLID = 4 + USER_SIGNED_IN = 100 + + +class Currency(IntEnum): + UNKNOWN = 0 + USD = 1 + CNY = 2 + + +UserRegionToCurrency = { + UserRegion.ZH_CN: Currency.CNY.name, + UserRegion.OTHER: Currency.USD.name, +} + + +class NewUserMethod(IntEnum): + EMAIL = 1 + MOBILE = 2 + + +class UserAccountProperty(IntEnum): + EMAIL_VERIFIED = 1 + MOBILE_VERIFIED = 2 + PAYMENT_SETUP = 4 + ACCEPT_REQUEST = 8 + READY_PROVIDER = 16 + MANAGE_PROJECT = 32 + + +class UserLoginAction(IntEnum): + VERIFY_EMAIL_WITH_AUTH_CODE = 0 + EXISTING_USER_PASSWORD_REQUIRED = 1 + NEW_USER_SET_PASSWORD = 2 + EMAIL_NOT_ASSOCIATED_WITH_USER = 3 + REVIEW_AND_REVISE_FLID = 4 + USER_SIGNED_IN = 100 + + +class AuthType(IntEnum): + MOBILE = 0 + EMAIL = 1 + PASSWORD = 2 + + +class DepotStatus(IntEnum): + TO_BE_CREATED = 0 + CREATED = 1 + DELETED = 2 + + +class UserAccountStatus(IntEnum): + TO_BE_CREATED = 0 + CREATED = 1 + DELETED = 2 + DEACTIVATED = 3 diff --git a/backend/models/user/models.py b/backend/models/user/models.py new file mode 100644 index 0000000..ee438fd --- /dev/null +++ b/backend/models/user/models.py @@ -0,0 +1,80 @@ +from typing import Optional, List + +from ..base_doc import BaseDoc + +from .constants import UserAccountProperty +from backend.models.permission.constants import ( + AdministrativeRole, + Capability, +) +from datetime import datetime +from common.constants.region import UserRegion +from .constants import AuthType + + +class UserAccountDoc(BaseDoc): + profile_id: Optional[str] + account_id: Optional[str] + service_plan_id: Optional[str] + properties: UserAccountProperty + capabilities: Capability + user_role: int = AdministrativeRole.NONE + preferred_region: UserRegion = UserRegion.ZH_CN + + class Settings: + name = "user_account" + + +class UserPasswordDoc(BaseDoc): + user_id: str + password: str + + class Settings: + name = "user_password" + + +class UserEmailDoc(BaseDoc): + user_id: str + email: str + + class Settings: + name = "user_email" + + +class UserMobileDoc(BaseDoc): + user_id: str + mobile: str + + class Settings: + name = "user_mobile" + + +class AuthCodeDoc(BaseDoc): + auth_code: str + method: str + method_type: AuthType + expiry: datetime + used: bool = False + + class Settings: + name = "user_auth_code" + +class UsageLogDoc(BaseDoc): + timestamp: datetime = datetime.utcnow() # timestamp + tenant_id: str # tenant id + operation: str # operation type + request_id: str # request id # TODO: use true one + status: str # operation status + latency_ms: int # latency time(milliseconds) + bytes_in: int # input bytes + bytes_out: int # output bytes + key_id: Optional[str] = None # API Key ID + extra: dict = {} # extra information + + class Settings: + name = "usage_log_doc" + indexes = [ + "tenant_id", + "request_id", + "key_id" + ] \ No newline at end of file diff --git a/backend/models/user_profile/__init__.py b/backend/models/user_profile/__init__.py new file mode 100644 index 0000000..c6b8da5 --- /dev/null +++ b/backend/models/user_profile/__init__.py @@ -0,0 +1,3 @@ +from .models import BasicProfileDoc, ProviderProfileDoc + +profile_models = [BasicProfileDoc, ProviderProfileDoc] diff --git a/backend/models/user_profile/models.py b/backend/models/user_profile/models.py new file mode 100644 index 0000000..445cbf3 --- /dev/null +++ b/backend/models/user_profile/models.py @@ -0,0 +1,103 @@ +from datetime import datetime +from typing import List, Optional +from pydantic import BaseModel, EmailStr +import re + +from decimal import Decimal +from common.constants.region import UserRegion +from ..base_doc import BaseDoc + + +class Tags(BaseModel): + skill: List[str] + + +class SelfIntro(BaseModel): + summary: str = "" + content_html: str = "" + tags: Tags + + +class Photo(BaseModel): + url: Optional[str] + base64: str + filename: str + + +class Email(BaseModel): + address: Optional[EmailStr] + verified: bool = False + + +class Mobile(BaseModel): + number: Optional[str] + verified: bool + + +class FLID(BaseModel): + identity: str + set_by: str + create_time: datetime + update_time: datetime + + +class Password(BaseModel): + set_up: bool + update_time: datetime + expiry: datetime + + +class BasicProfileDoc(BaseDoc): + user_id: str + first_name: str = "" + last_name: str = "" + spoken_language: List[str] = [] + self_intro: SelfIntro + photo: Photo + email: Email + mobile: Mobile + FLID: FLID + password: Password + region: int = UserRegion.OTHER + time_zone: Optional[str] = None + + class Settings: + name = "basic_profile" + indexes = [ + "user_id", # Add index for fast querying by user_id + "email.address", # This adds an index for the 'email.address' field + # Compound text index for fuzzy search across multiple fields + [("first_name", "text"), ("last_name", "text"), ("email.address", "text")], + ] + + @classmethod + async def fuzzy_search(cls, query: str) -> List["BasicProfileDoc"]: + # Create a case-insensitive regex pattern for partial matching + regex = re.compile(f".*{query}.*", re.IGNORECASE) + + # Perform a search on first_name, last_name, and email fields using $or + results = await cls.find( + { + "$or": [ + {"first_name": {"$regex": regex}}, + {"last_name": {"$regex": regex}}, + {"email.address": {"$regex": regex}}, + ] + } + ).to_list() + + return results + + +class ExpectedSalary(BaseModel): + currency: str = "USD" + hourly: Decimal = 0.0 + + +class ProviderProfileDoc(BaseDoc): + user_id: str + expected_salary: ExpectedSalary + accepting_request: bool = False + + class Settings: + name = "provider_profile" diff --git a/backend/services/auth/user_auth_service.py b/backend/services/auth/user_auth_service.py new file mode 100644 index 0000000..38cde61 --- /dev/null +++ b/backend/services/auth/user_auth_service.py @@ -0,0 +1,60 @@ +from backend.infra.auth.user_auth_handler import ( + UserAuthHandler, +) +from backend.models.user.constants import ( + AuthType, +) +from typing import Optional + + +class UserAuthService: + def __init__(self): + self.user_auth_handler = UserAuthHandler() + + async def get_user_id_by_email(self, email: str) -> Optional[str]: + return await self.user_auth_handler.get_user_id_by_email(email) + + async def verify_email_with_code(self, email: str, code: str) -> bool: + return await self.user_auth_handler.verify_email_code(email, code) + + async def is_password_reset_required(self, user_id: str) -> bool: + return await self.user_auth_handler.is_password_reset_required(user_id) + + async def is_flid_reset_required(self, user_id: str) -> bool: + return await self.user_auth_handler.is_flid_reset_required(user_id) + + async def is_flid_available(self, user_flid: str) -> bool: + return await self.user_auth_handler.is_flid_available(user_flid) + + async def get_user_flid(self, user_id: str) -> str: + return await self.user_auth_handler.get_flid(user_id) + + async def update_flid(self, user_id: str, user_flid: str) -> str: + return await self.user_auth_handler.update_flid(user_id, user_flid) + + async def generate_auth_code_for_object( + self, deliver_object: str, auth_type: AuthType + ) -> str: + return await self.user_auth_handler.generate_auth_code( + deliver_object, auth_type + ) + + async def verify_user_with_password(self, user_id: str, password: str) -> bool: + return await self.user_auth_handler.verify_user_with_password(user_id, password) + + async def reset_password(self, user_id: str): + return await self.user_auth_handler.reset_password(user_id) + + async def save_password_auth_method( + self, user_id: str, user_flid: str, password: str + ): + return await self.user_auth_handler.save_password_auth_method( + user_id, user_flid, password + ) + + async def save_password_auth_method_no_depot( + self, user_id: str, user_flid: str, password: str + ): + return await self.user_auth_handler.save_password_auth_method_no_depot( + user_id, user_flid, password + ) diff --git a/backend/services/code_depot/code_depot_service.py b/backend/services/code_depot/code_depot_service.py new file mode 100644 index 0000000..8b79797 --- /dev/null +++ b/backend/services/code_depot/code_depot_service.py @@ -0,0 +1,132 @@ +from common.log.module_logger import ModuleLogger + +from typing import List, Dict, Optional + +from common.config.app_settings import app_settings + +import httpx +import asyncio + + +async def fetch_with_retry(url, method="GET", retries=3, backoff=1.0, **kwargs): + """ + A generic function for making HTTP requests with retry logic. + + Parameters: + url (str): The endpoint URL. + method (str): HTTP method ('GET', 'POST', etc.). + retries (int): Number of retry attempts. + backoff (float): Backoff time in seconds. + kwargs: Additional arguments for the request. + + Returns: + httpx.Response: The response object. + """ + for attempt in range(retries): + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: + if method.upper() == "GET": + response = await client.get(url, **kwargs) + elif method.upper() == "POST": + response = await client.post(url, **kwargs) + response.raise_for_status() # Check for HTTP errors + return response + except (httpx.ReadTimeout, httpx.RequestError) as exc: + if attempt < retries - 1: + await asyncio.sleep(backoff * (2**attempt)) # Exponential backoff + continue + else: + raise exc + + +class CodeDepotService: + def __init__(self) -> None: + self.depot_endpoint = ( + app_settings.DEVSVC_WEBAPI_URL_BASE.rstrip("/") + "/depot/" + ) + self.module_logger = ModuleLogger(sender_id="CodeDepotService") + + async def check_depot_name_availabe(self, code_depot_name: str) -> bool: + api_url = self.depot_endpoint + "check-depot-name-available/" + code_depot_name + response = await fetch_with_retry(api_url) + return response.json() + + async def create_code_depot(self, product_id, code_depot_name) -> Optional[str]: + api_url = self.depot_endpoint + "create-code-depot" + response = await fetch_with_retry( + api_url, + method="POST", + json={"product_id": product_id, "code_depot_name": code_depot_name}, + ) + return response.json() + + async def get_depot_ssh_url(self, code_depot_name: str) -> str: + api_url = self.depot_endpoint + "get-depot-ssh-url/" + code_depot_name + response = await fetch_with_retry(api_url) + return response.json() + + async def get_depot_http_url(self, code_depot_name: str) -> str: + api_url = self.depot_endpoint + "get-depot-http-url/" + code_depot_name + response = await fetch_with_retry(api_url) + return response.json() + + async def get_depot_http_url_with_user_name( + self, code_depot_name: str, user_name: str + ) -> str: + api_url = ( + self.depot_endpoint + + "get-depot-http-url-with-user-name/" + + code_depot_name + + "/" + + user_name + ) + response = await fetch_with_retry(api_url) + return response.json() + + async def get_depot_users(self, code_depot_name: str) -> List[str]: + api_url = self.depot_endpoint + "get-depot-users/" + code_depot_name + response = await fetch_with_retry(api_url) + return response.json() + + async def update_depot_user_password(self, user_name: str, password: str) -> bool: + api_url = self.depot_endpoint + "update-depot-password-for-user" + response = await fetch_with_retry( + api_url, + method="POST", + json={"user_name": user_name, "password": password}, + ) + return response.json() + + async def create_depot_user( + self, user_name: str, password: str, email: str + ) -> bool: + api_url = self.depot_endpoint + "create-depot-user" + response = await fetch_with_retry( + api_url, + method="POST", + json={"user_name": user_name, "password": password, "email": email}, + ) + return response.json() + + async def grant_user_depot_access( + self, user_name: str, code_depot_name: str + ) -> bool: + api_url = self.depot_endpoint + "grant-user-depot-access" + response = await fetch_with_retry( + api_url, + method="POST", + json={"user_name": user_name, "code_depot_name": code_depot_name}, + ) + return response.json() + + async def generate_statistic_result( + self, code_depot_name: str + ) -> Optional[Dict[str, any]]: + api_url = self.depot_endpoint + "generate-statistic-result/" + code_depot_name + response = await fetch_with_retry(api_url) + return response.json() + + async def fetch_code_depot(self, code_depot_id: str) -> Optional[Dict[str, any]]: + api_url = self.depot_endpoint + "fetch-code-depot/" + code_depot_id + response = await fetch_with_retry(api_url) + return response.json() diff --git a/backend/services/notification/notification_service.py b/backend/services/notification/notification_service.py new file mode 100644 index 0000000..84bddd5 --- /dev/null +++ b/backend/services/notification/notification_service.py @@ -0,0 +1,37 @@ +import httpx +from common.config.app_settings import app_settings +from typing import Dict, List + + +class NotificationService: + def __init__(self): + self.notification_api_url = app_settings.NOTIFICATION_WEBAPI_URL_BASE.rstrip( + "/" + ) + + async def send_notification( + self, + sender_id: str, + channels: List[str], + receiver_id: str, + subject: str, + event: str, + properties: Dict, + ) -> bool: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.notification_api_url}/send_notification", + json={ + "sender_id": sender_id, + "channels": channels, + "receiver_id": receiver_id, + "subject": subject, + "event": event, + "properties": properties, + }, + ) + if response.status_code == 200: + return True + else: + # Optionally log or handle errors here + return False diff --git a/backend/services/permission/permission_service.py b/backend/services/permission/permission_service.py new file mode 100644 index 0000000..41e1820 --- /dev/null +++ b/backend/services/permission/permission_service.py @@ -0,0 +1,47 @@ +from typing import Optional, Dict, Any + +from fastapi.exceptions import RequestValidationError + +from backend.infra.permission.permission_handler import PermissionHandler +from backend.models.permission.models import PermissionDoc +from bson import ObjectId + +class PermissionService: + def __init__(self): + self.permission_handler = PermissionHandler() + + async def create_permission(self, permission_key: str, permission_name: str, description: Optional[str] = None) -> PermissionDoc: + """Create a new permission document""" + return await self.permission_handler.create_permission(permission_key, permission_name, description) + + async def update_permission(self, permission_id: str, permission_key: Optional[str] = None, permission_name: Optional[str] = None, description: Optional[str] = None) -> PermissionDoc: + """Update an existing permission document by id""" + return await self.permission_handler.update_permission(permission_id, permission_key, permission_name, description) + + async def create_or_update_permission(self, permission_key: str, permission_name: str, custom_permission_id: Optional[str], description: Optional[str] = None) -> PermissionDoc: + """Create or update a permission document""" + return await self.permission_handler.create_or_update_permission(permission_key, permission_name, custom_permission_id, description) + + async def query_permissions(self, permission_key: Optional[str] = None, permission_name: Optional[str] = None, page: int = 1, page_size: int = 10) -> Dict[str, Any]: + """Query permissions with pagination and fuzzy search""" + if page < 1 or page_size < 1: + raise RequestValidationError("page and page_size must be positive integers.") + skip = (page - 1) * page_size + docs, total = await self.permission_handler.query_permissions(permission_key, permission_name, skip, page_size) + return { + "items": [doc.model_dump() for doc in docs], + "total": total, + "page": page, + "page_size": page_size + } + async def query_permissions_no_pagination(self, permission_id: Optional[str] = None, permission_key: Optional[str] = None, permission_name: Optional[str] = None) -> Dict[str, Any]: + """Query permissions fuzzy search""" + docs, total = await self.permission_handler.query_permissions_no_pagination(permission_id, permission_key, permission_name) + return { + "items": [doc.model_dump() for doc in docs], + "total": total + } + + async def delete_permission(self, permission_id: str) -> None: + """Delete a permission document after checking if it is referenced by any role""" + return await self.permission_handler.delete_permission(permission_id) \ No newline at end of file diff --git a/backend/services/permission/role_service.py b/backend/services/permission/role_service.py new file mode 100644 index 0000000..16cb4da --- /dev/null +++ b/backend/services/permission/role_service.py @@ -0,0 +1,56 @@ +from typing import Optional, Dict, Any, List + +from fastapi.exceptions import RequestValidationError + +from backend.infra.permission.role_handler import RoleHandler +from backend.models.permission.models import RoleDoc +from bson import ObjectId + +class RoleService: + def __init__(self): + self.role_handler = RoleHandler() + + async def create_role(self, role_key: str, role_name: str, role_description: Optional[str], role_level: int) -> RoleDoc: + """Create a new role, ensuring role_key and role_name are unique and not empty""" + + doc = await self.role_handler.create_role(role_key, role_name, role_description, role_level) + return doc + + async def update_role(self, role_id: str, role_key: str, role_name: str, role_description: Optional[str], role_level: int) -> RoleDoc: + """Update an existing role, ensuring role_key and role_name are unique and not empty""" + + doc = await self.role_handler.update_role(role_id, role_key, role_name, role_description, role_level) + return doc + + async def create_or_update_role(self, role_key: str, role_name: str, role_level: int, custom_role_id: Optional[str], role_description: Optional[str] = None) -> RoleDoc: + """Create or update a role document""" + return await self.role_handler.create_or_update_role(role_key, role_name, role_level, custom_role_id, role_description) + + async def query_roles(self, role_key: Optional[str], role_name: Optional[str], page: int = 1, page_size: int = 10) -> Dict[str, Any]: + """Query roles with pagination and fuzzy search by role_key and role_name""" + if page < 1 or page_size < 1: + raise RequestValidationError("page and page_size must be positive integers.") + skip = (page - 1) * page_size + docs, total = await self.role_handler.query_roles(role_key, role_name, skip, page_size) + return { + "items": [doc.model_dump() for doc in docs], + "total": total, + "page": page, + "page_size": page_size + } + + async def query_roles_no_pagination(self, role_id: Optional[str] = None, role_key: Optional[str] = None, role_name: Optional[str] = None) -> Dict[str, Any]: + """Query roles fuzzy search without pagination""" + docs, total = await self.role_handler.query_roles_no_pagination(role_id, role_key, role_name) + return { + "items": [doc.model_dump() for doc in docs], + "total": total + } + + async def assign_permissions_to_role(self, role_id: str, permission_ids: List[str]) -> RoleDoc: + """Assign permissions to a role by updating the permission_ids field""" + return await self.role_handler.assign_permissions_to_role(role_id, permission_ids) + + async def delete_role(self, role_id: str) -> None: + """Delete a role document after checking if it is referenced by any user""" + return await self.role_handler.delete_role(role_id) \ No newline at end of file diff --git a/backend/services/user/user_management_service.py b/backend/services/user/user_management_service.py new file mode 100644 index 0000000..5db8887 --- /dev/null +++ b/backend/services/user/user_management_service.py @@ -0,0 +1,117 @@ +from backend.models.permission.models import UserRoleDoc +from common.log.module_logger import ModuleLogger +from typing import Optional, List, Tuple + +from backend.models.user.constants import ( + NewUserMethod, + UserAccountProperty, +) +from backend.models.user.models import UserAccountDoc +from backend.models.permission.constants import ( + AdministrativeRole, + Capability, +) +from backend.infra.auth.user_auth_handler import ( + UserAuthHandler, +) +from backend.infra.user_profile.user_profile_handler import ( + UserProfileHandler, +) +from backend.infra.permission.user_role_handler import ( + UserRoleHandler, +) +from common.log.log_utils import log_entry_exit_async +from common.constants.region import UserRegion + + +class UserManagementService: + def __init__(self) -> None: + self.user_auth_handler = UserAuthHandler() + self.user_profile_handler = UserProfileHandler() + self.user_role_handler = UserRoleHandler() + self.module_logger = ModuleLogger(sender_id=UserManagementService) + + @log_entry_exit_async + async def create_new_user_account( + self, method: NewUserMethod, region: UserRegion + ) -> UserAccountDoc: + """create a new user account document in DB + + Args: + method (NewUserMethod): the method the new user came from + region : preferred user region detected via the user log-in website + + Returns: + str: id of user account + """ + if NewUserMethod.EMAIL == method: + user_account = await self.user_profile_handler.create_new_user_account( + UserAccountProperty.EMAIL_VERIFIED, + Capability.VISITOR, + AdministrativeRole.PERSONAL, + region, + ) + + elif NewUserMethod.MOBILE == method: + user_account = await self.user_profile_handler.create_new_user_account( + UserAccountProperty.EMAIL_VERIFIED, + Capability.VISITOR, + AdministrativeRole.PERSONAL, + region, + ) + + # Create other doc in collections for the new user + # TODO: Should convert to notification + # await UserAchievement(str(user_account.id)).create_activeness_achievement() + return user_account + + async def initialize_new_user_data( + self, + user_id: str, + method: NewUserMethod, + email_address: str = None, + mobile_number: str = None, + region: UserRegion = UserRegion.ZH_CN, + time_zone: Optional[str] = "UTC", + ): + """Init data for the new user + + Args: + user_id (str): user id + method (NewUserMethod): the method the new user came from + + Returns: + result: True if initilize data for the new user successfully, else return False + """ + + # create basic and provider profile doc for the new user + if NewUserMethod.EMAIL == method: + await self.user_profile_handler.create_basic_profile( + user_id, email_address, True, None, False, False, region, time_zone + ) + await self.user_auth_handler.save_email_auth_method(user_id, email_address) + elif NewUserMethod.MOBILE == method: + await self.user_profile_handler.create_basic_profile( + user_id, None, False, mobile_number, True, False, region, time_zone + ) + else: + return False + + await self.user_profile_handler.create_provider_profile(user_id) + return True + + async def get_account_by_id(self, user_id: str) -> UserAccountDoc: + return await self.user_profile_handler.get_account_by_id(user_id) + + async def assign_roles_to_user(self, user_id: str, role_ids: List[str]) -> UserRoleDoc: + """Assign roles to a user by updating or creating the UserRoleDoc""" + return await self.user_role_handler.assign_roles_to_user(user_id, role_ids) + + async def get_role_and_permission_by_user_id(self, user_id: str) -> Tuple[List[str], List[str]]: + """Get user role names and permission keys by user id + Args: + user_id (str): user id + Returns: + Tuple[List[str], List[str]]: user role names and permission keys + """ + return await self.user_role_handler.get_role_and_permission_by_user_id(user_id) diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/config/__init__.py b/common/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/config/app_settings.py b/common/config/app_settings.py new file mode 100644 index 0000000..804e341 --- /dev/null +++ b/common/config/app_settings.py @@ -0,0 +1,38 @@ +import os +from pydantic_settings import BaseSettings + + +class AppSettings(BaseSettings): + NAME: str = "authentication" + APP_NAME: str = NAME + APP_ENV: str = os.environ.get("APP_ENV", "alpha") + + METRICS_ENABLED: bool = False + PROBES_ENABLED: bool = True + + JWT_SECRET_KEY: str = "" + JWT_ALGORITHM: str = "HS256" + + ACCESS_TOKEN_EXPIRE_MINUTES: int = 3600 + REFRESH_TOKEN_EXPIRE_DAYS: int = 1 + + DEVSVC_WEBAPI_URL_BASE: str = "http://localhost:8007/api/devsvc/" + NOTIFICATION_WEBAPI_URL_BASE: str = "http://localhost:8003/api/notification/" + + AUTH_SERVICE_ENDPOINT: str = "" + + MONGODB_URI: str = "" + MONGODB_NAME: str = "" + TENANT_CACHE_MAX: int = 64 + SYSTEM_USER_ID: str = "117f191e810c19729de860aa" + + LOG_BASE_PATH: str = "./log" + BACKEND_LOG_FILE_NAME: str = APP_NAME + APPLICATION_ACTIVITY_LOG: str = APP_NAME + "-application-activity" + + class Config: + env_file = ".myapp.env" + env_file_encoding = "utf-8" + + +app_settings = AppSettings() diff --git a/common/config/log_settings.py b/common/config/log_settings.py new file mode 100644 index 0000000..7995968 --- /dev/null +++ b/common/config/log_settings.py @@ -0,0 +1,17 @@ +import os +from dataclasses import dataclass +from .app_settings import app_settings + + +@dataclass +class LogSettings: + LOG_PATH_BASE: str = app_settings.LOG_BASE_PATH + LOG_RETENTION: str = os.environ.get("LOG_RETENTION", "30 days") + LOG_ROTATION: str = os.environ.get("LOG_ROTATION", "00:00") # midnight + MAX_BACKUP_FILES: int = int(os.environ.get("LOG_BACKUP_FILES", 5)) + LOG_ROTATION_BYTES: int = int(os.environ.get("LOG_ROTATION_BYTES", 10 * 1024 * 1024)) # 10 MB + APP_NAME: str = app_settings.APP_NAME + ENVIRONMENT: str = app_settings.APP_ENV + + +log_settings = LogSettings() diff --git a/common/constants/__init__.py b/common/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/constants/jwt_constants.py b/common/constants/jwt_constants.py new file mode 100644 index 0000000..496b745 --- /dev/null +++ b/common/constants/jwt_constants.py @@ -0,0 +1,2 @@ +USER_ROLE_NAMES = "role_names" +USER_PERMISSIONS = "user_permissions" \ No newline at end of file diff --git a/common/constants/region.py b/common/constants/region.py new file mode 100644 index 0000000..ac87f72 --- /dev/null +++ b/common/constants/region.py @@ -0,0 +1,5 @@ +from enum import IntEnum + +class UserRegion(IntEnum): + OTHER = 0 + ZH_CN = 1 diff --git a/common/exception/__init__.py b/common/exception/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/exception/exceptions.py b/common/exception/exceptions.py new file mode 100644 index 0000000..2c489c3 --- /dev/null +++ b/common/exception/exceptions.py @@ -0,0 +1,23 @@ +class DoesNotExistError(Exception): + def __init__(self, message: str = "Does Not Exist"): + self.message = message + + +class AuthenticationError(Exception): + def __init__(self, message: str = "Unauthorized"): + self.message = message + + +class AuthorizationError(Exception): + def __init__(self, message: str = "Forbidden"): + self.message = message + + +class InvalidOperationError(Exception): + def __init__(self, message: str = "Invalid Operation"): + self.message = message + + +class InvalidDataError(Exception): + def __init__(self, message: str = "Invalid Data"): + self.message = message diff --git a/common/log/__init__.py b/common/log/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/log/application_logger.py b/common/log/application_logger.py new file mode 100644 index 0000000..67ec321 --- /dev/null +++ b/common/log/application_logger.py @@ -0,0 +1,12 @@ +from .base_logger import LoggerBase +from common.config.app_settings import app_settings + +class ApplicationLogger(LoggerBase): + def __init__(self, application_activities: dict[str, any] = {}) -> None: + extra_fileds = {} + if application_activities: + extra_fileds.update(application_activities) + super().__init__( + logger_name=app_settings.APPLICATION_ACTIVITY_LOG, + extra_fileds=extra_fileds, + ) diff --git a/common/log/base_logger.py b/common/log/base_logger.py new file mode 100644 index 0000000..24f7bb0 --- /dev/null +++ b/common/log/base_logger.py @@ -0,0 +1,136 @@ +from loguru import logger as guru_logger +from common.config.log_settings import log_settings +from typing import Dict, Any, Optional +import socket +import json +import threading +import os +import sys +import inspect +import logging + +from common.log.json_sink import JsonSink + +class LoggerBase: + binded_loggers = {} + logger_lock = threading.Lock() + + def __init__(self, logger_name: str, extra_fileds: dict[str, any]) -> None: + self.__logger_name = logger_name + self.extra_fileds = extra_fileds + with LoggerBase.logger_lock: + if self.__logger_name in LoggerBase.binded_loggers: + self.logger = LoggerBase.binded_loggers[self.__logger_name] + return + + log_filename = f"{log_settings.LOG_PATH_BASE}/{self.__logger_name}.log" + log_level = "INFO" + rotation_bytes = int(log_settings.LOG_ROTATION_BYTES or 10 * 1024 * 1024) + + guru_logger.remove() + + file_sink = JsonSink( + log_file_path=log_filename, + rotation_size_bytes=rotation_bytes, + max_backup_files=log_settings.MAX_BACKUP_FILES + ) + guru_logger.add( + sink=file_sink, + level=log_level, + filter=lambda record: record["extra"].get("topic") == self.__logger_name, + ) + + guru_logger.add( + sink=sys.stderr, + level=log_level, + format="{level} - {time:YYYY-MM-DD HH:mm:ss} - <{extra[log_file]}:{extra[log_line]}> - {extra[properties_str]} - {message}", + filter=lambda record: record["extra"].get("topic") == self.__logger_name, + ) + + host_name = socket.gethostname() + host_ip = socket.gethostbyname(host_name) + self.logger = guru_logger.bind( + topic=self.__logger_name, + host_ip=host_ip, + host_name=host_name, + app=log_settings.APP_NAME, + env=log_settings.ENVIRONMENT, + ) + with LoggerBase.logger_lock: + LoggerBase.binded_loggers[self.__logger_name] = self.logger + + def _get_log_context(self) -> dict: + frame = inspect.currentframe().f_back.f_back + filename = os.path.basename(frame.f_code.co_filename) + lineno = frame.f_lineno + return {"log_file": filename, "log_line": lineno} + + def _prepare_properties(self, properties: Optional[Dict[str, Any]]) -> Dict[str, Any]: + props = {} if properties is None else properties.copy() + props_str = json.dumps(props, ensure_ascii=False) if props else "{}" + return props, props_str + + async def log_event(self, sender_id: str, receiver_id: str, subject: str, event: str, properties: dict[str, any], text: str = "") -> None: + props, props_str = self._prepare_properties(properties) + context = self._get_log_context() + local_logger = self.logger.bind(sender_id=sender_id, receiver_id=receiver_id, subject=subject, event=event, properties=props, properties_str=props_str, **context) + local_logger.info(text) + + async def log_exception(self, sender_id: str, receiver_id: str, subject: str, exception: Exception, text: str = "", properties: dict[str, any] = None) -> None: + props, props_str = self._prepare_properties(properties) + context = self._get_log_context() + local_logger = self.logger.bind(sender_id=sender_id, receiver_id=receiver_id, subject=subject, event="exception", properties=props, properties_str=props_str, exception=exception, **context) + local_logger.exception(text) + + async def log_info(self, sender_id: str, receiver_id: str, subject: str, text: str = "", properties: dict[str, any] = None) -> None: + props, props_str = self._prepare_properties(properties) + context = self._get_log_context() + local_logger = self.logger.bind(sender_id=sender_id, receiver_id=receiver_id, subject=subject, event="information", properties=props, properties_str=props_str, **context) + local_logger.info(text) + + async def log_warning(self, sender_id: str, receiver_id: str, subject: str, text: str = "", properties: dict[str, any] = None) -> None: + props, props_str = self._prepare_properties(properties) + context = self._get_log_context() + local_logger = self.logger.bind(sender_id=sender_id, receiver_id=receiver_id, subject=subject, event="warning", properties=props, properties_str=props_str, **context) + local_logger.warning(text) + + async def log_error(self, sender_id: str, receiver_id: str, subject: str, text: str = "", properties: dict[str, any] = None) -> None: + props, props_str = self._prepare_properties(properties) + context = self._get_log_context() + local_logger = self.logger.bind(sender_id=sender_id, receiver_id=receiver_id, subject=subject, event="error", properties=props, properties_str=props_str, **context) + local_logger.error(text) + + @staticmethod + def configure_uvicorn_logging(): + print("📢 Setting up uvicorn logging interception...") + + # Intercept logs from these loggers + intercept_loggers = ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"] + + class InterceptHandler(logging.Handler): + def emit(self, record): + level = ( + guru_logger.level(record.levelname).name + if guru_logger.level(record.levelname, None) + else record.levelno + ) + frame, depth = logging.currentframe(), 2 + while frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + guru_logger.opt(depth=depth, exception=record.exc_info).log( + level, + f"[{record.name}] {record.getMessage()}", + ) + + # Replace default handlers + logging.root.handlers.clear() + logging.root.setLevel(logging.INFO) + logging.root.handlers = [InterceptHandler()] + + # Configure specific uvicorn loggers + for logger_name in intercept_loggers: + logging_logger = logging.getLogger(logger_name) + logging_logger.handlers.clear() # Remove default handlers + logging_logger.propagate = True # Ensure propagation through Loguru diff --git a/common/log/business_metric_logger.py b/common/log/business_metric_logger.py new file mode 100644 index 0000000..95383ab --- /dev/null +++ b/common/log/business_metric_logger.py @@ -0,0 +1,25 @@ +from .base_logger import LoggerBase +from common.config.app_settings import app_settings +import json + + +class BusinessMetricLogger(LoggerBase): + def __init__(self, business_metrics: dict[str, any] = {}) -> None: + extra_fileds = {} + if business_metrics: + extra_fileds.update(business_metrics) + super().__init__( + logger_name=app_settings.BUSINESS_METRIC_LOG, + extra_fileds=extra_fileds, + ) + + + async def log_metrics(self, business_metrics: dict[str, any] = {}) -> None: + return await super().log_event( + sender_id="business_metric_manager", + receiver_id="business_metric_logger", + subject="metrics", + event="logging", + properties=business_metrics, + text="business metric logged" + ) diff --git a/common/log/function_logger.py b/common/log/function_logger.py new file mode 100644 index 0000000..4388a5e --- /dev/null +++ b/common/log/function_logger.py @@ -0,0 +1,50 @@ +from .application_logger import ApplicationLogger + + +class FunctionLogger(ApplicationLogger): + def __init__(self, sender_id: str, receiver_id:str) -> None: + super().__init__() + self.event_sender_id = sender_id + self.event_receiver_id = receiver_id + self.event_subject = "function" + + async def log_enter(self, function: str, file: str): + return await super().log_event( + sender_id=self.event_sender_id, + receiver_id=self.event_receiver_id, + subject=self.event_subject, + event="enter", + properties={ + "function": function, + "file": file, + }, + text="Enter:{} of {}".format(function, file) + ) + + async def log_exit(self, function: str, file: str, excution_time_in_ns: int): + return await super().log_event( + sender_id=self.event_sender_id, + receiver_id=self.event_receiver_id, + subject=self.event_subject, + event="exit", + properties={ + "function": function, + "file": file, + "excution_time_in_ns": excution_time_in_ns + }, + text="Exit:{} of {}".format(function, file) + ) + + async def log_exception(self, exception: Exception, function: str, file: str, excution_time_in_ns: int) -> None: + return await super().log_exception( + sender_id=self.event_sender_id, + receiver_id=self.event_receiver_id, + subject=self.event_subject, + exception=exception, + text="Exception:{} of {}".format(function, file), + properties={ + "function": function, + "file": file, + "excution_time_in_ns": excution_time_in_ns + }, + ) diff --git a/common/log/json_sink.py b/common/log/json_sink.py new file mode 100644 index 0000000..867ef42 --- /dev/null +++ b/common/log/json_sink.py @@ -0,0 +1,85 @@ +import json +import datetime +import traceback +from pathlib import Path +from typing import Optional + +class JsonSink: + def __init__( + self, + log_file_path: str, + rotation_size_bytes: int = 10 * 1024 * 1024, + max_backup_files: int = 5, + ): + self.log_file_path = Path(log_file_path) + self.rotation_size = rotation_size_bytes + self.max_backup_files = max_backup_files + self._open_log_file() + + def _open_log_file(self): + # ensure the parent directory exists + parent_dir = self.log_file_path.parent + if not parent_dir.exists(): + parent_dir.mkdir(parents=True, exist_ok=True) + self.log_file = self.log_file_path.open("a", encoding="utf-8") + + def _should_rotate(self) -> bool: + return self.log_file_path.exists() and self.log_file_path.stat().st_size >= self.rotation_size + + def _rotate(self): + self.log_file.close() + timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + rotated_path = self.log_file_path.with_name(f"{self.log_file_path.stem}_{timestamp}{self.log_file_path.suffix}") + self.log_file_path.rename(rotated_path) + self._cleanup_old_backups() + self._open_log_file() + + def _cleanup_old_backups(self): + parent = self.log_file_path.parent + stem = self.log_file_path.stem + suffix = self.log_file_path.suffix + + backup_files = sorted( + parent.glob(f"{stem}_*{suffix}"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + for old_file in backup_files[self.max_backup_files:]: + try: + old_file.unlink() + except Exception as e: + print(f"Failed to delete old backup {old_file}: {e}") + + def __call__(self, message): + record = message.record + if self._should_rotate(): + self._rotate() + + log_entry = { + "level": record["level"].name.lower(), + "timestamp": int(record["time"].timestamp() * 1000), + "text": record["message"], + "fields": record["extra"].get("properties", {}), + "context": { + "app": record["extra"].get("app"), + "env": record["extra"].get("env"), + "log_file": record["extra"].get("log_file"), + "log_line": record["extra"].get("log_line"), + "topic": record["extra"].get("topic"), + "sender_id": record["extra"].get("sender_id"), + "receiver_id": record["extra"].get("receiver_id"), + "subject": record["extra"].get("subject"), + "event": record["extra"].get("event"), + "host_ip": record["extra"].get("host_ip"), + "host_name": record["extra"].get("host_name"), + }, + "stacktrace": None + } + + if record["exception"]: + exc_type, exc_value, exc_tb = record["exception"] + log_entry["stacktrace"] = traceback.format_exception(exc_type, exc_value, exc_tb) + + self.log_file.write(json.dumps(log_entry, ensure_ascii=False, default=str) + "\n") + self.log_file.flush() diff --git a/common/log/log_utils.py b/common/log/log_utils.py new file mode 100644 index 0000000..579dee8 --- /dev/null +++ b/common/log/log_utils.py @@ -0,0 +1,25 @@ +import os +from .function_logger import FunctionLogger +import time +import functools + + +def log_entry_exit_async(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + file_path = os.path.relpath(func.__code__.co_filename) + function_logger = FunctionLogger(sender_id="log_entry_exit_async", receiver_id="function_logger") + start_time = time.process_time_ns() + try: + await function_logger.log_enter(func.__name__, file_path) + result = await func(*args, **kwargs) + await function_logger.log_exit(func.__name__, file_path, time.process_time_ns() - start_time) + return result + except Exception as exception: + await function_logger.log_exception( + exception=exception, + function=func.__name__, + file=file_path, + excution_time_in_ns=time.process_time_ns() - start_time) + raise + return wrapper diff --git a/common/log/module_logger.py b/common/log/module_logger.py new file mode 100644 index 0000000..3426b0b --- /dev/null +++ b/common/log/module_logger.py @@ -0,0 +1,46 @@ +from .application_logger import ApplicationLogger + + +class ModuleLogger(ApplicationLogger): + def __init__(self, sender_id: str) -> None: + super().__init__() + self.event_sender_id = sender_id + self.event_receiver_id = "ModuleLogger" + self.event_subject = "module" + + async def log_exception(self, exception: Exception, text: str = "Exception", properties: dict[str, any] = None) -> None: + return await super().log_exception( + sender_id=self.event_sender_id, + receiver_id=self.event_receiver_id, + subject=self.event_subject, + exception=exception, + text=text, + properties=properties, + ) + + async def log_info(self, info: str, properties: dict[str, any] = None) -> None: + return await super().log_info( + sender_id=self.event_sender_id, + receiver_id=self.event_receiver_id, + subject=self.event_subject, + text=info, + properties=properties, + ) + + async def log_warning(self, warning: str, properties: dict[str, any] = None) -> None: + return await super().log_warning( + sender_id=self.event_sender_id, + receiver_id=self.event_receiver_id, + subject=self.event_subject, + text=warning, + properties=properties, + ) + + async def log_error(self, error: str, properties: dict[str, any] = None) -> None: + return await super().log_error( + sender_id=self.event_sender_id, + receiver_id=self.event_receiver_id, + subject=self.event_subject, + text=error, + properties=properties, + ) diff --git a/common/log/user_logger.py b/common/log/user_logger.py new file mode 100644 index 0000000..d931975 --- /dev/null +++ b/common/log/user_logger.py @@ -0,0 +1,14 @@ +from .base_logger import LoggerBase +from common.config.app_settings import app_settings + +import json + + +class UserLogger(LoggerBase): + def __init__(self, user_activities: dict[str, any] = {}) -> None: + extra_fileds = {} + if user_activities: + extra_fileds.update(user_activities) + super().__init__( + logger_name=app_settings.USER_ACTIVITY_LOG, extra_fileds=extra_fileds + ) diff --git a/common/probes/__init__.py b/common/probes/__init__.py new file mode 100644 index 0000000..4071df8 --- /dev/null +++ b/common/probes/__init__.py @@ -0,0 +1,140 @@ +import logging +from enum import Enum +from typing import Optional, Callable, Tuple, Dict +import inspect +from datetime import datetime, timezone + +# ProbeType is an Enum that defines the types of probes that can be registered. +class ProbeType(Enum): + LIVENESS = "liveness" + READINESS = "readiness" + STARTUP = "startup" + +# ProbeResult is a class that represents the result of a probe check. +class ProbeResult: + def __init__(self, success: bool, message: str = "ok", data: Optional[dict] = None): + self.success = success + self.message = message + self.data = data or {} + + def to_dict(self) -> dict: + return { + "success": self.success, + "message": self.message, + "data": self.data + } + +# Probe is a class that represents a probe that can be registered. +class Probe: + def __init__(self, type: ProbeType, path: str, check_fn: Callable, name: Optional[str] = None): + self.type = type + self.path = path + self.check_fn = check_fn + self.name = name or f"{type.value}-{id(self)}" + + async def execute(self) -> ProbeResult: + try: + result = self.check_fn() + if inspect.isawaitable(result): + result = await result + + if isinstance(result, ProbeResult): + return result + elif isinstance(result, bool): + return ProbeResult(result, "ok" if result else "failed") + else: + return ProbeResult(True, "ok") + except Exception as e: + return ProbeResult(False, str(e)) + +# ProbeGroup is a class that represents a group of probes that can be checked together. +class ProbeGroup: + def __init__(self, path: str): + self.path = path + self.probes: Dict[str, Probe] = {} + + def add_probe(self, probe: Probe): + self.probes[probe.name] = probe + + async def check_all(self) -> Tuple[bool, dict]: + results = {} + all_success = True + + for name, probe in self.probes.items(): + result = await probe.execute() + results[name] = result.to_dict() + if not result.success: + all_success = False + + return all_success, results + +# FrameworkAdapter is an abstract class that defines the interface for framework-specific probe adapters. +class FrameworkAdapter: + async def handle_request(self, group: ProbeGroup): + all_success, results = await group.check_all() + status_code = 200 if all_success else 503 + return {"status": "ok" if all_success else "failed", "payload": results, "timestamp": int(datetime.now(timezone.utc).timestamp())}, status_code + + def register_route(self, path: str, handler: Callable): + raise NotImplementedError + +# ProbeManager is a class that manages the registration of probes and their corresponding framework adapters. +class ProbeManager: + _default_paths = { + ProbeType.LIVENESS: "/_/livez", + ProbeType.READINESS: "/_/readyz", + ProbeType.STARTUP: "/_/healthz" + } + + def __init__(self): + self.groups: Dict[str, ProbeGroup] = {} + self.adapters: Dict[str, FrameworkAdapter] = {} + self._startup_complete = False + + def register_adapter(self, framework: str, adapter: FrameworkAdapter): + self.adapters[framework] = adapter + logging.info(f"Registered probe adapter ({adapter}) for framework: {framework}") + + def register( + self, + type: ProbeType, + check_func: Optional[Callable] = None, + path: Optional[str] = None, + prefix: str = "", + name: Optional[str] = None, + frameworks: Optional[list] = None + ): + path = path or self._default_paths.get(type, "/_/healthz") + if prefix: + path = f"{prefix}{path}" + + if type == ProbeType.STARTUP and check_func is None: + check_func = self._default_startup_check + + probe = Probe(type, path, check_func or (lambda: True), name) + + if path not in self.groups: + self.groups[path] = ProbeGroup(path) + self.groups[path].add_probe(probe) + + for framework in (frameworks or ["default"]): + self._register_route(framework, path) + logging.info(f"Registered {type.value} probe route ({path}) for framework: {framework}") + + def _register_route(self, framework: str, path: str): + if framework not in self.adapters: + return + + adapter = self.adapters[framework] + group = self.groups[path] + + async def handler(): + return await adapter.handle_request(group) + + adapter.register_route(path, handler) + + def _default_startup_check(self) -> bool: + return self._startup_complete + + def mark_startup_complete(self): + self._startup_complete = True \ No newline at end of file diff --git a/common/probes/adapters.py b/common/probes/adapters.py new file mode 100644 index 0000000..2ecd38a --- /dev/null +++ b/common/probes/adapters.py @@ -0,0 +1,15 @@ +from . import FrameworkAdapter +from fastapi.responses import JSONResponse +from typing import Callable + +# FastAPIAdapter is a class that implements the FrameworkAdapter interface for FastAPI. +class FastAPIAdapter(FrameworkAdapter): + def __init__(self, app): + self.app = app + + def register_route(self,path: str, handler: Callable): + async def wrapper(): + data, status_code = await handler() + return JSONResponse(content=data, status_code=status_code) + + self.app.add_api_route(path, wrapper, methods=["GET"]) diff --git a/common/token/token_manager.py b/common/token/token_manager.py new file mode 100644 index 0000000..1758fb7 --- /dev/null +++ b/common/token/token_manager.py @@ -0,0 +1,130 @@ +from datetime import datetime, timedelta, timezone +import uuid +from typing import Dict, List +from jose import jwt, JWTError +from common.config.app_settings import app_settings +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from common.constants.jwt_constants import USER_ROLE_NAMES, USER_PERMISSIONS + + +class CurrentUser: + def __init__(self, user_id: str, user_role_names: List[str], user_permission_keys: List[str]): + self.user_id = user_id + self.user_role_names = user_role_names + self.user_permission_keys = user_permission_keys + + def has_all_permissions(self, permissions: List[str]) -> bool: + """Check if the user has all the specified permissions""" + if not permissions: + return True + return all(p in self.user_permission_keys for p in permissions) + + def has_any_permissions(self, permissions: List[str]) -> bool: + """Check if the user has at least one of the specified permissions""" + if not permissions: + return True + return any(p in self.user_permission_keys for p in permissions) + + +security = HTTPBearer() + + +class TokenManager: + def __init__(self): + self.secret_key = app_settings.JWT_SECRET_KEY + self.algorithm = app_settings.JWT_ALGORITHM + self.access_token_expire_minutes = app_settings.ACCESS_TOKEN_EXPIRE_MINUTES + self.refresh_token_expire_days = app_settings.REFRESH_TOKEN_EXPIRE_DAYS + + def create_access_token(self, subject: Dict[str, str]) -> str: + """ + Generates an access token with a short expiration time. + """ + expire = datetime.now(timezone.utc) + timedelta( + minutes=self.access_token_expire_minutes + ) + to_encode = { + "exp": expire, + "subject": subject, # User identity information + "type": "access", # Indicate token type + } + return jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm) + + def create_refresh_token(self, subject: Dict[str, str]) -> str: + """ + Generates a refresh token with a longer expiration time. + """ + expire = datetime.now(timezone.utc) + timedelta( + days=self.refresh_token_expire_days + ) + to_encode = { + "exp": expire, + "subject": subject, # User identity information + "type": "refresh", # Indicate token type + "jti": str(uuid.uuid4()), # Unique identifier for the refresh token + } + return jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm) + + def decode_token(self, token: str) -> Dict: + """ + Decodes a JWT token and returns the payload. + """ + try: + payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) + return payload + except JWTError: + raise ValueError("Invalid token") + + def verify_refresh_token(self, token: str) -> bool: + """ + Verifies a refresh token to ensure it is valid and not expired. + """ + try: + payload = self.decode_token(token) + return True + except ValueError: + return False + + def refresh_access_token(self, refresh_token: str, subject: Dict[str, str]) -> str: + """ + Verifies the refresh token and creates a new access token. + """ + if self.verify_refresh_token(refresh_token): + return self.create_access_token(subject) + else: + raise ValueError("Invalid refresh token") + + async def get_current_user(self, credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser: + """ + Returns the current user object for the given credentials. + """ + try: + payload = self.decode_token(credentials.credentials) + user = payload.get("subject") + if not user or "id" not in user: + raise HTTPException(status_code=401, detail="Invalid authentication token") + return CurrentUser(user.get("id"), user.get(USER_ROLE_NAMES), user.get(USER_PERMISSIONS)) + except JWTError: + raise HTTPException(status_code=401, detail="Invalid authentication token") + + def has_all_permissions(self, permissions: List[str]): + """Check if the user has all the specified permissions""" + + def inner_dependency(current_user: CurrentUser = Depends(self.get_current_user)): + if not current_user.has_all_permissions(permissions): + raise HTTPException(status_code=403, detail="Not allowed") + return True + + return inner_dependency + + def has_any_permissions(self, permissions: List[str]): + """Check if the user has at least one of the specified permissions""" + + def inner_dependency(current_user: CurrentUser = Depends(self.get_current_user)): + if not current_user.has_any_permissions(permissions): + raise HTTPException(status_code=403, detail="Not allowed") + return True + + return inner_dependency diff --git a/common/utils/date.py b/common/utils/date.py new file mode 100644 index 0000000..79451d1 --- /dev/null +++ b/common/utils/date.py @@ -0,0 +1,22 @@ +import datetime +from datetime import timedelta, timezone + + +def get_sunday(date): + return date - datetime.timedelta(days=date.weekday()) + timedelta(days=6) + + +def get_last_sunday_dates(number, include_current_week=True): + now_utc = datetime.datetime.now(timezone.utc) + today = datetime.datetime(now_utc.year, now_utc.month, now_utc.day) + if include_current_week: + days_to_last_sunday = (6 - today.weekday()) % 7 + last_sunday = today + datetime.timedelta(days=days_to_last_sunday) + else: + days_to_last_sunday = (today.weekday() - 6) % 7 + last_sunday = today - datetime.timedelta(days=days_to_last_sunday) + last_n_sundays = [] + for i in range(number): + sunday = last_sunday - datetime.timedelta(days=i * 7) + last_n_sundays.append(sunday.date()) + return last_n_sundays diff --git a/common/utils/region.py b/common/utils/region.py new file mode 100644 index 0000000..4212e02 --- /dev/null +++ b/common/utils/region.py @@ -0,0 +1,13 @@ +from common.constants.region import UserRegion + + +class RegionHandler: + def __init__(self): + self._zh_cn_patterns = [".cn", "cn.", "host"] + + def detect_from_host(self, host: str) -> UserRegion: + # Now we set user preferred region based on host + for parttern in self._zh_cn_patterns: + if parttern in host.lower(): + return UserRegion.ZH_CN + return UserRegion.OTHER \ No newline at end of file diff --git a/common/utils/string.py b/common/utils/string.py new file mode 100644 index 0000000..359c6b4 --- /dev/null +++ b/common/utils/string.py @@ -0,0 +1,87 @@ +import random +import re +import jieba +from typing import List + +SKILL_TAGS = [ + "C++", + "Java", + "Python", + "TypeScript", + "iOS", + "Android", + "Web", + "Javascript", + "Vue", + "Go", +] + +# dynamically update skill tags? maybe based on the most commonly extracted keywords to help the system adapt to change +def updateSkillTags(string): + SKILL_TAGS.append(string) + + +def generate_auth_code(): + filtered = "0123456789" + code = "".join(random.choice(filtered) for i in range(6)) + return code + + +# TODO: Need to optimize +def generate_self_intro_summary(content_html: str) -> str: + element_html = re.compile("<.*?>") + content_text = re.sub(element_html, "", content_html).strip() + return content_text[:50] + + +# TODO: Need to optimize +def extract_skill_tags(content_html: str) -> List[str]: + element_html = re.compile("<.*?>") + content_text = re.sub(element_html, "", content_html).strip() + words = set([word.lower() for word in jieba.cut(content_text) if word.strip()]) + + results = [] + for tag in SKILL_TAGS: + if tag.lower() in words: + results.append(tag) + return results + + +def extract_title(content_html: str) -> List[str]: + element_html = re.compile("<.*?>") + content_text = re.sub(element_html, "\n", content_html).strip() + + cut_point_indexes = [] + for cut_point in [".", ",", ";", "\r", "\n"]: + result = content_text.find(cut_point) + if result > 0: + cut_point_indexes.append(result) + + title = ( + content_text[: min(cut_point_indexes)] + if len(cut_point_indexes) > 0 + else content_text + ) + return title + + +def check_password_complexity(password): + lowercase_pattern = r"[a-z]" + uppercase_pattern = r"[A-Z]" + digit_pattern = r"\d" + special_pattern = r'[!@#$%^&*(),.?":{}|<>]' + + password_lowercase_one = bool(re.search(lowercase_pattern, password)) + password_uppercase_one = bool(re.search(uppercase_pattern, password)) + password_digit_one = bool(re.search(digit_pattern, password)) + password_special_one = bool(re.search(special_pattern, password)) + + if ( + password_lowercase_one + and password_uppercase_one + and password_digit_one + and password_special_one + ): + return True + else: + return False diff --git a/local.env b/local.env new file mode 100644 index 0000000..64a2f53 --- /dev/null +++ b/local.env @@ -0,0 +1,14 @@ +APP_NAME=authentication +SERVER_HOST=0.0.0.0 +SERVER_PORT=7900 +AUTH_SERVICE_ENDPOINT=http://localhost:9000/api/v1/ +AUTH_SERVICE_PORT=9000 +CONTAINER_APP_ROOT=/app +BACKEND_LOG_FILE_NAME=$APP_NAME +APPLICATION_ACTIVITY_LOG=authentication-activity +MONGODB_URI=mongodb://localhost:27017/ +MONGODB_NAME=freeleaps2 +MONGODB_PORT=27017 +TENANT_CACHE_MAX=64 +JWT_SECRET_KEY=ea84edf152976b2fcec12b78aa8e45bc26a5cf0ef61bf16f5c317ae33b3fd8b0 + diff --git a/main.py b/main.py deleted file mode 100644 index 0555cd2..0000000 --- a/main.py +++ /dev/null @@ -1,16 +0,0 @@ -from app.setup_app import create_app -from app.utils.config import settings -from app.utils.logger import logger - -app = create_app() - -if __name__ == "__main__": - import uvicorn - - logger.info(f"Starting server on {settings.UVICORN_HOST}:{settings.UVICORN_PORT}...") - uvicorn.run( - 'main:app', - host=settings.UVICORN_HOST, - port=settings.UVICORN_PORT, - reload=settings.is_development(), - ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7fa0908..26ce4b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,18 @@ -fastapi==0.115.12 -starlette==0.46.2 -pydantic_settings==2.9.1 -uvicorn==0.34.2 \ No newline at end of file +fastapi==0.114.0 +fastapi-mail==1.4.1 +fastapi-jwt==0.2.0 +pika==1.3.2 +pydantic==2.9.2 +loguru==0.7.2 +uvicorn==0.23.2 +beanie==1.21.0 +jieba==0.42.1 +sendgrid +aio-pika +httpx +pydantic-settings +python-jose +passlib[bcrypt] +prometheus-fastapi-instrumentator==7.0.2 +pytest==8.4.1 +pytest-asyncio==0.21.2 \ No newline at end of file diff --git a/start_fastapi.sh b/start_fastapi.sh new file mode 100755 index 0000000..1b533b4 --- /dev/null +++ b/start_fastapi.sh @@ -0,0 +1,26 @@ +#! /bin/bash +rp=$(dirname "$(realpath '$1'))") +pushd $rp + +APP_NAME=authentication +VENV_DIR=venv + +. .env + +if [ -d "$VENV_DIR" ] +then + echo "Folder $VENV_DIR exists. Proceed to next steps" +else + echo "Folder $VENV_DIR dosen't exist. create it" + sudo apt install python3-pip + python3 -m pip install virtualenv + python3 -m virtualenv $VENV_DIR +fi + +source $VENV_DIR/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +set -a; source local.env; set +a + +uvicorn webapi.main:app --reload --host 0.0.0.0 --port $SERVER_PORT +popd diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api_tests/__init__.py b/tests/api_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api_tests/permission/README.md b/tests/api_tests/permission/README.md new file mode 100644 index 0000000..9d0ad81 --- /dev/null +++ b/tests/api_tests/permission/README.md @@ -0,0 +1,86 @@ +# Permission API Test Report + +## How to Run the Tests + + **Run all permission API tests with coverage:** + ```bash + pytest --cov=authentication --cov-report=term-missing tests/api_tests/permission/ + ``` + +--- + +## Test Results Summary + +- **Total tests collected:** 26 +- **All tests passed.** +- **Warnings:** + - Deprecation warnings from Pydantic/Beanie (upgrade recommended for future compatibility). + - Coverage warning: `Module authentication was never imported. (module-not-imported)` + +--- + +## Test Case Explanations + +### test_create_permission.py + +- **test_create_permission_success** + Admin user can create a permission with valid data. +- **test_create_permission_fail_duplicate_key/name** + Creating a permission with duplicate key or name fails. +- **test_create_permission_fail_empty_key/name** + Creating a permission with empty key or name fails. +- **test_create_permission_success_empty_description** + Description is optional. +- **test_create_permission_fail_by_non_admin** + Non-admin user cannot create permissions. +- **test_create_permission_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can create permissions. + +### test_delete_permission.py + +- **test_delete_permission_success** + Admin user can delete a permission. +- **test_delete_permission_fail_not_found** + Deleting a non-existent permission fails. +- **test_delete_default_permission_fail** + Default permissions cannot be deleted. +- **test_delete_permission_fail_by_non_admin** + Non-admin user cannot delete permissions. +- **test_delete_permission_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can delete permissions. + +### test_update_permission.py + +- **test_update_permission_success** + Admin user can update a permission. +- **test_update_permission_fail_not_found** + Updating a non-existent permission fails. +- **test_update_permission_fail_duplicate_key/name** + Updating to a duplicate key or name fails. +- **test_update_permission_fail_empty_key/name** + Updating with empty key or name fails. +- **test_update_default_permission_fail** + Default permissions cannot be updated. +- **test_update_permission_fail_by_non_admin** + Non-admin user cannot update permissions. +- **test_update_permission_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can update permissions. + +### test_query_permission.py + +- **test_query_all_permissions** + Query all permissions, expect a list. +- **test_query_permissions_by_key/name** + Query permissions by key or name (fuzzy search). +- **test_query_permissions_pagination** + Query permissions with pagination. + +--- + +## Summary + +- These tests ensure that only admin users can manage permissions, and that permission can be delegated by granting the admin role to other users. +- Each test case is designed to verify both positive and negative scenarios, including permission escalation and proper error handling. +- **Coverage reporting is not working** due to import or execution issues—fix this for a complete report. + +--- diff --git a/tests/api_tests/permission/__init__.py b/tests/api_tests/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api_tests/permission/conftest.py b/tests/api_tests/permission/conftest.py new file mode 100644 index 0000000..b96c630 --- /dev/null +++ b/tests/api_tests/permission/conftest.py @@ -0,0 +1,21 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture(scope="session") +def authentication_web() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web + + +@pytest.fixture(scope="session") +def authentication_web_of_temp_user1() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + user = authentication_web.create_temporary_user() + authentication_web.user_email = user["email"] + authentication_web.password = user["password"] + authentication_web.user_id = user["user_id"] + authentication_web.login() + return authentication_web diff --git a/tests/api_tests/permission/test_create_permission.py b/tests/api_tests/permission/test_create_permission.py new file mode 100644 index 0000000..ab579be --- /dev/null +++ b/tests/api_tests/permission/test_create_permission.py @@ -0,0 +1,143 @@ +import pytest +import random +from tests.base.authentication_web import AuthenticationWeb + + +class TestCreatePermission: + @pytest.mark.asyncio + async def test_create_permission_success(self, authentication_web: AuthenticationWeb): + """Test creating a permission successfully with valid and unique permission_key and permission_name.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_success_{suffix}", + "permission_name": f"Test Permission Success {suffix}", + "description": "Permission for testing success" + } + response = await authentication_web.create_permission(perm_data) + assert response.status_code == 200 + json = response.json() + assert json["permission_key"] == perm_data["permission_key"] + assert json["permission_name"] == perm_data["permission_name"] + assert json["description"] == perm_data["description"] + assert json["id"] is not None + assert json["created_at"] is not None + assert json["updated_at"] is not None + + @pytest.mark.asyncio + async def test_create_permission_fail_duplicate_key(self, authentication_web: AuthenticationWeb): + """Test creating a permission fails when permission_key is duplicated.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_dup_{suffix}", + "permission_name": f"Test Permission DupKey {suffix}", + "description": "desc" + } + await authentication_web.create_permission(perm_data) + perm_data2 = { + "permission_key": f"test_perm_key_dup_{suffix}", + "permission_name": f"Test Permission DupKey2 {suffix}", + "description": "desc2" + } + response = await authentication_web.create_permission(perm_data2) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_permission_fail_duplicate_name(self, authentication_web: AuthenticationWeb): + """Test creating a permission fails when permission_name is duplicated.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_dupname1_{suffix}", + "permission_name": f"Test Permission DupName {suffix}", + "description": "desc" + } + await authentication_web.create_permission(perm_data) + perm_data2 = { + "permission_key": f"test_perm_key_dupname2_{suffix}", + "permission_name": f"Test Permission DupName {suffix}", + "description": "desc2" + } + response = await authentication_web.create_permission(perm_data2) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_permission_fail_empty_key(self, authentication_web: AuthenticationWeb): + """Test creating a permission fails when permission_key is empty.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": "", + "permission_name": f"Test Permission EmptyKey {suffix}", + "description": "desc" + } + response = await authentication_web.create_permission(perm_data) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_permission_fail_empty_name(self, authentication_web: AuthenticationWeb): + """Test creating a permission fails when permission_name is empty.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_emptyname_{suffix}", + "permission_name": "", + "description": "desc" + } + response = await authentication_web.create_permission(perm_data) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_permission_success_empty_description(self, authentication_web: AuthenticationWeb): + """Test creating a permission successfully when description is None (optional field).""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_emptydesc_{suffix}", + "permission_name": f"Test Permission EmptyDesc {suffix}", + "description": None + } + response = await authentication_web.create_permission(perm_data) + assert response.status_code == 200 + json = response.json() + assert json["permission_key"] == perm_data["permission_key"] + assert json["permission_name"] == perm_data["permission_name"] + assert json["description"] is None or json["description"] == "" + + @pytest.mark.asyncio + async def test_create_permission_fail_by_non_admin(self, authentication_web_of_temp_user1: AuthenticationWeb): + """Test creating a permission fails by non-admin user (no permission).""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_nonadmin_{suffix}", + "permission_name": f"Test Permission NonAdmin {suffix}", + "description": "desc" + } + response = await authentication_web_of_temp_user1.create_permission(perm_data) + assert response.status_code == 403 or response.status_code == 401 + + @pytest.mark.asyncio + async def test_create_permission_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test creating a permission succeeds after granting admin role to a new temporary user and re-login.""" + # Create a new temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to create permission + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_tempadmin_{suffix}", + "permission_name": f"Test Permission TempAdmin {suffix}", + "description": "desc" + } + response = await temp_authentication_web.create_permission(perm_data) + assert response.status_code == 200 + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/api_tests/permission/test_delete_permission.py b/tests/api_tests/permission/test_delete_permission.py new file mode 100644 index 0000000..6604daf --- /dev/null +++ b/tests/api_tests/permission/test_delete_permission.py @@ -0,0 +1,85 @@ +import pytest +import random + +from backend.models.permission.constants import DefaultPermissionEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestDeletePermission: + @pytest.mark.asyncio + async def test_delete_permission_success(self, authentication_web: AuthenticationWeb): + """Test deleting a permission successfully.""" + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"delperm_{suffix}", + "permission_name": f"delperm_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + resp = await authentication_web.delete_permission({"permission_id": perm_id}) + assert resp.status_code == 200 + assert resp.json()["success"] is True + + @pytest.mark.asyncio + async def test_delete_permission_fail_not_found(self, authentication_web: AuthenticationWeb): + """Test deleting a permission fails when permission_id does not exist.""" + resp = await authentication_web.delete_permission({"permission_id": "000000000000000000000000"}) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_delete_default_permission_fail(self, authentication_web: AuthenticationWeb): + """Test deleting a default permission fails. Default permission cannot be deleted.""" + # Query a default role + resp = await authentication_web.query_permissions( + params={"page": 1, "page_size": 2, "permission_key": DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key}) + json = resp.json() + default_permission_id = json["items"][0]["id"] + resp = await authentication_web.delete_permission(perm_data={"permission_id": default_permission_id}) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_delete_permission_fail_by_non_admin(self, authentication_web: AuthenticationWeb, authentication_web_of_temp_user1: AuthenticationWeb): + """Test deleting a permission fails by non-admin user (no permission).""" + # Create a permission as admin + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"delperm_nonadmin_{suffix}", + "permission_name": f"delperm_nonadmin_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Try to delete as temp user + resp = await authentication_web_of_temp_user1.delete_permission({"permission_id": perm_id}) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_delete_permission_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test deleting a permission succeeds after granting admin role to a new temporary user and re-login.""" + # Create a new temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + # Create a permission as admin + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"delperm_tempadmin_{suffix}", + "permission_name": f"delperm_tempadmin_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to delete as temp user + resp = await temp_authentication_web.delete_permission({"permission_id": perm_id}) + assert resp.status_code == 200 + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file diff --git a/tests/api_tests/permission/test_query_permission.py b/tests/api_tests/permission/test_query_permission.py new file mode 100644 index 0000000..2f8f4cc --- /dev/null +++ b/tests/api_tests/permission/test_query_permission.py @@ -0,0 +1,57 @@ +import random +import pytest +from tests.base.authentication_web import AuthenticationWeb + + +class TestQueryPermission: + @pytest.mark.asyncio + async def test_query_all_permissions(self, authentication_web: AuthenticationWeb): + """Test querying all permissions returns a list.""" + resp = await authentication_web.query_permissions({}) + assert resp.status_code == 200 + json = resp.json() + assert "items" in json + assert "total" in json + + @pytest.mark.asyncio + async def test_query_permissions_by_key(self, authentication_web: AuthenticationWeb): + """Test querying permissions by permission_key with fuzzy search.""" + suffix = str(random.randint(10000, 99999)) + await authentication_web.create_permission({ + "permission_key": f"querykey_{suffix}", + "permission_name": f"querykey_{suffix}", + "description": "desc" + }) + resp = await authentication_web.query_permissions({"permission_key": f"querykey_{suffix}"}) + assert resp.status_code == 200 + json = resp.json() + assert any(f"querykey_{suffix}" in item["permission_key"] for item in json["items"]) + + @pytest.mark.asyncio + async def test_query_permissions_by_name(self, authentication_web: AuthenticationWeb): + """Test querying permissions by permission_name with fuzzy search.""" + suffix = str(random.randint(10000, 99999)) + await authentication_web.create_permission({ + "permission_key": f"queryname_{suffix}", + "permission_name": f"queryname_{suffix}", + "description": "desc" + }) + resp = await authentication_web.query_permissions({"permission_name": f"queryname_{suffix}"}) + assert resp.status_code == 200 + json = resp.json() + assert any(f"queryname_{suffix}" in item["permission_name"] for item in json["items"]) + + @pytest.mark.asyncio + async def test_query_permissions_pagination(self, authentication_web: AuthenticationWeb): + """Test querying permissions with pagination.""" + resp = await authentication_web.query_permissions({"page": 1, "page_size": 2}) + assert resp.status_code == 200 + json = resp.json() + assert "items" in json + assert "total" in json + assert "page" in json + assert "page_size" in json + + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file diff --git a/tests/api_tests/permission/test_update_permission.py b/tests/api_tests/permission/test_update_permission.py new file mode 100644 index 0000000..4cf1e20 --- /dev/null +++ b/tests/api_tests/permission/test_update_permission.py @@ -0,0 +1,205 @@ +import pytest +import random + +from backend.models.permission.constants import DefaultPermissionEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestUpdatePermission: + @pytest.mark.asyncio + async def test_update_permission_success(self, authentication_web: AuthenticationWeb): + """Test updating a permission successfully with valid and unique fields.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"update_perm_key_{suffix}", + "permission_name": f"Update Permission {suffix}", + "description": "desc" + } + create_resp = await authentication_web.create_permission(perm_data) + perm_id = create_resp.json()["id"] + update_data = { + "permission_id": perm_id, + "permission_key": f"update_perm_key_{suffix}_new", + "permission_name": f"Update Permission {suffix} New", + "description": "desc new" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 200 + json = resp.json() + assert json["permission_key"] == update_data["permission_key"] + assert json["permission_name"] == update_data["permission_name"] + assert json["description"] == update_data["description"] + + @pytest.mark.asyncio + async def test_update_permission_fail_not_found(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_id does not exist.""" + suffix = str(random.randint(10000, 99999)) + update_data = { + "permission_id": "000000000000000000000000", + "permission_key": f"notfound_key_{suffix}", + "permission_name": f"NotFound Permission {suffix}", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_permission_fail_duplicate_key(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_key is duplicated.""" + suffix = str(random.randint(10000, 99999)) + perm1 = await authentication_web.create_permission({ + "permission_key": f"dupkey1_{suffix}", + "permission_name": f"dupkey1_{suffix}", + "description": "desc" + }) + perm2 = await authentication_web.create_permission({ + "permission_key": f"dupkey2_{suffix}", + "permission_name": f"dupkey2_{suffix}", + "description": "desc" + }) + perm2_id = perm2.json()["id"] + update_data = { + "permission_id": perm2_id, + "permission_key": f"dupkey1_{suffix}", + "permission_name": f"dupkey2_{suffix}_new", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_permission_fail_duplicate_name(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_name is duplicated.""" + suffix = str(random.randint(10000, 99999)) + perm1 = await authentication_web.create_permission({ + "permission_key": f"dupname1_{suffix}", + "permission_name": f"dupname1_{suffix}", + "description": "desc" + }) + perm2 = await authentication_web.create_permission({ + "permission_key": f"dupname2_{suffix}", + "permission_name": f"dupname2_{suffix}", + "description": "desc" + }) + perm2_id = perm2.json()["id"] + update_data = { + "permission_id": perm2_id, + "permission_key": f"dupname2_{suffix}_new", + "permission_name": f"dupname1_{suffix}", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_permission_fail_empty_key(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_key is empty.""" + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"emptykey_{suffix}", + "permission_name": f"emptykey_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + update_data = { + "permission_id": perm_id, + "permission_key": "", + "permission_name": f"emptykey_{suffix}_new", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_permission_fail_empty_name(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_name is empty.""" + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"emptyname_{suffix}", + "permission_name": f"emptyname_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + update_data = { + "permission_id": perm_id, + "permission_key": f"emptyname_{suffix}_new", + "permission_name": "", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_default_permission_fail(self, authentication_web: AuthenticationWeb): + """Test updating a default permission fails. Default permission cannot be updated.""" + suffix = str(random.randint(10000, 99999)) + # Query a default role + resp = await authentication_web.query_permissions( + params={"page": 1, "page_size": 2, "permission_key": DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key}) + json = resp.json() + default_permission = json["items"][0] + resp = await authentication_web.update_permission(perm_data={ + "permission_id": default_permission["id"], + "permission_key": f"{default_permission['permission_key']}_{suffix}_update", + "permission_name": f"{default_permission['permission_name']}_{suffix}_update", + "description": "desc", + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_permission_fail_by_non_admin(self, authentication_web: AuthenticationWeb, authentication_web_of_temp_user1: AuthenticationWeb): + """Test updating a permission fails by non-admin user (no permission).""" + # Create a permission as admin + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"updateperm_nonadmin_{suffix}", + "permission_name": f"updateperm_nonadmin_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + update_data = { + "permission_id": perm_id, + "permission_key": f"updateperm_nonadmin_{suffix}_new", + "permission_name": f"updateperm_nonadmin_{suffix}_new", + "description": "desc new" + } + resp = await authentication_web_of_temp_user1.update_permission(update_data) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_update_permission_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test updating a permission succeeds after granting admin role to a new temporary user and re-login.""" + # Create a new temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + # Create a permission as admin + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"updateperm_tempadmin_{suffix}", + "permission_name": f"updateperm_tempadmin_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to update as temp user + update_data = { + "permission_id": perm_id, + "permission_key": f"updateperm_tempadmin_{suffix}_new", + "permission_name": f"updateperm_tempadmin_{suffix}_new", + "description": "desc new" + } + resp = await temp_authentication_web.update_permission(update_data) + assert resp.status_code == 200 + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file diff --git a/tests/api_tests/role/README.md b/tests/api_tests/role/README.md new file mode 100644 index 0000000..9e93dd8 --- /dev/null +++ b/tests/api_tests/role/README.md @@ -0,0 +1,99 @@ +# Role API Test Report + +## How to Run the Tests + +**Run all role API tests:** +```bash +pytest --tb=short tests/api_tests/role/ +``` + +--- + +## Test Results Summary + +- **Total tests collected:** 33 +- **All tests passed.** +- **Warnings:** + - Deprecation warnings from Pydantic/Beanie (upgrade recommended for future compatibility). + +--- + +## Test Case Explanations + +### test_assign_permissions.py +- **test_assign_permissions_success** + Assign multiple permissions to a role successfully. +- **test_assign_permissions_fail_role_not_found** + Assigning permissions to a non-existent role fails. +- **test_assign_permissions_fail_permission_not_found** + Assigning a non-existent permission to a role fails. +- **test_assign_permissions_fail_empty_permission_ids** + Assigning with an empty permission list fails. +- **test_assign_permissions_fail_empty_role_id** + Assigning with an empty role ID fails. +- **test_assign_permissions_remove_duplicates** + Assigning duplicate permission IDs results in de-duplication. +- **test_assign_permissions_to_default_role** + Assigning permissions to a default role (should succeed if not restricted). + +### test_create_role.py +- **test_create_role_success** + Admin user can create a role with valid and unique data. +- **test_create_role_fail_duplicate_role_key/name** + Creating a role with duplicate key or name fails. +- **test_create_role_fail_empty_role_key/name** + Creating a role with empty key or name fails. +- **test_create_role_success_empty_description** + Description is optional. +- **test_create_role_fail_by_non_admin** + Non-admin user cannot create roles. +- **test_create_role_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can create roles. + +### test_delete_role.py +- **test_delete_role_success** + Admin user can delete a role. +- **test_delete_role_fail_not_found** + Deleting a non-existent role fails. +- **test_delete_default_role_fail** + Default roles cannot be deleted. +- **test_delete_role_fail_by_non_admin** + Non-admin user cannot delete roles. +- **test_delete_role_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can delete roles. + +### test_query_role.py +- **test_query_all_roles** + Query all roles, expect a list. +- **test_query_roles_by_key/name** + Query roles by key or name (fuzzy search). +- **test_query_roles_pagination** + Query roles with pagination. + +### test_update_role.py +- **test_update_role_success** + Admin user can update a role with valid and unique data. +- **test_update_role_fail_not_found** + Updating a non-existent role fails. +- **test_update_role_fail_duplicate_key/name** + Updating to a duplicate key or name fails. +- **test_update_role_fail_empty_key/name** + Updating with empty key or name fails. +- **test_update_default_role_fail** + Default roles cannot be updated. +- **test_update_role_fail_by_non_admin** + Non-admin user cannot update roles. +- **test_update_role_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can update roles. + +--- + +## Summary + +- These tests ensure that only admin users can manage roles, and that permission can be delegated by granting the admin role to other users. +- Each test case is designed to verify both positive and negative scenarios, including permission escalation and proper error handling. +- **Coverage reporting is not included in this report.** + +--- + +If you need a more detailed, markdown-formatted report with actual coverage numbers, please enable coverage and re-run the tests. \ No newline at end of file diff --git a/tests/api_tests/role/__init__.py b/tests/api_tests/role/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api_tests/role/conftest.py b/tests/api_tests/role/conftest.py new file mode 100644 index 0000000..3731f6c --- /dev/null +++ b/tests/api_tests/role/conftest.py @@ -0,0 +1,21 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture(scope="session") +def authentication_web()->AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web + + +@pytest.fixture(scope="session") +def authentication_web_of_temp_user1() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + user = authentication_web.create_temporary_user() + authentication_web.user_email = user["email"] + authentication_web.password = user["password"] + authentication_web.user_id = user["user_id"] + authentication_web.login() + return authentication_web \ No newline at end of file diff --git a/tests/api_tests/role/test_assign_permissions.py b/tests/api_tests/role/test_assign_permissions.py new file mode 100644 index 0000000..3deec5e --- /dev/null +++ b/tests/api_tests/role/test_assign_permissions.py @@ -0,0 +1,163 @@ +import pytest +import random +from typing import List +from backend.models.permission.constants import DefaultRoleEnum, DefaultPermissionEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestAssignPermissionsToRole: + @pytest.mark.asyncio + async def test_assign_permissions_success(self, authentication_web: AuthenticationWeb): + """Test assigning permissions to a role successfully.""" + # Create a role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignperm_role_{suffix}", + "role_name": f"AssignPerm Role {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + # Create two permissions + perm1 = await authentication_web.create_permission({ + "permission_key": f"assignperm_key1_{suffix}", + "permission_name": f"AssignPerm Permission1 {suffix}", + "description": "desc" + }) + perm2 = await authentication_web.create_permission({ + "permission_key": f"assignperm_key2_{suffix}", + "permission_name": f"AssignPerm Permission2 {suffix}", + "description": "desc" + }) + perm_ids = [perm1.json()["id"], perm2.json()["id"]] + # Assign permissions + resp = await authentication_web.assign_permissions_to_role({ + "role_id": role_id, + "permission_ids": perm_ids + }) + assert resp.status_code == 200 + json = resp.json() + assert set(json["permission_ids"]) == set(perm_ids) + + @pytest.mark.asyncio + async def test_assign_permissions_fail_role_not_found(self, authentication_web: AuthenticationWeb): + """Test assigning permissions fails when role_id does not exist.""" + # Create a permission + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"assignperm_key_nf_{suffix}", + "permission_name": f"AssignPerm PermissionNF {suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + resp = await authentication_web.assign_permissions_to_role({ + "role_id": "000000000000000000000000", + "permission_ids": [perm_id] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_permissions_fail_permission_not_found(self, authentication_web: AuthenticationWeb): + """Test assigning permissions fails when a permission_id does not exist.""" + # Create a role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignperm_role_nf_{suffix}", + "role_name": f"AssignPerm RoleNF {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + resp = await authentication_web.assign_permissions_to_role({ + "role_id": role_id, + "permission_ids": ["000000000000000000000000"] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_permissions_fail_empty_permission_ids(self, authentication_web: AuthenticationWeb): + """Test assigning permissions fails when permission_ids is empty.""" + # Create a role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignperm_role_empty_{suffix}", + "role_name": f"AssignPerm RoleEmpty {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + resp = await authentication_web.assign_permissions_to_role({ + "role_id": role_id, + "permission_ids": [] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_permissions_fail_empty_role_id(self, authentication_web: AuthenticationWeb): + """Test assigning permissions fails when role_id is empty.""" + # Create a permission + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"assignperm_key_emptyrole_{suffix}", + "permission_name": f"AssignPerm PermissionEmptyRole {suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + resp = await authentication_web.assign_permissions_to_role({ + "role_id": "", + "permission_ids": [perm_id] + }) + assert resp.status_code == 422 or resp.status_code == 400 or resp.status_code == 500 + + @pytest.mark.asyncio + async def test_assign_permissions_remove_duplicates(self, authentication_web: AuthenticationWeb): + """Test assigning permissions with duplicate permission_ids removes duplicates.""" + # Create a role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignperm_role_dup_{suffix}", + "role_name": f"AssignPerm RoleDup {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + # Create a permission + perm = await authentication_web.create_permission({ + "permission_key": f"assignperm_key_dup_{suffix}", + "permission_name": f"AssignPerm PermissionDup {suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Assign duplicate permission_ids + resp = await authentication_web.assign_permissions_to_role({ + "role_id": role_id, + "permission_ids": [perm_id, perm_id, perm_id] + }) + assert resp.status_code == 200 + json = resp.json() + assert json["permission_ids"] == [perm_id] + + @pytest.mark.asyncio + async def test_assign_permissions_to_default_role(self, authentication_web: AuthenticationWeb): + """Test assigning permissions to a default role (should succeed if not restricted).""" + # Query default admin role + resp = await authentication_web.query_roles({"role_key": DefaultRoleEnum.ADMIN.value.role_key}) + json = resp.json() + default_role_id = json["items"][0]["id"] + # Create a permission + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"assignperm_key_default_{suffix}", + "permission_name": f"AssignPerm PermissionDefault {suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Try to assign permission to default role + resp = await authentication_web.assign_permissions_to_role({ + "role_id": default_role_id, + "permission_ids": [perm_id, *json["items"][0]["permission_ids"]] + }) + assert resp.status_code in [200] + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file diff --git a/tests/api_tests/role/test_create_role.py b/tests/api_tests/role/test_create_role.py new file mode 100644 index 0000000..8d4c488 --- /dev/null +++ b/tests/api_tests/role/test_create_role.py @@ -0,0 +1,159 @@ +import pytest +import random +from tests.base.authentication_web import AuthenticationWeb + + +class TestCreateRole: + + @pytest.mark.asyncio + async def test_create_role_success(self, authentication_web: AuthenticationWeb): + """Test creating a role successfully with valid and unique role_key and role_name.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_success_{suffix}", + "role_name": f"Test Role Success {suffix}", + "role_description": "Role for testing success", + "role_level": 1 + } + response = await authentication_web.create_role(role_data) + assert response.status_code == 200 + json = response.json() + assert json["role_key"] == role_data["role_key"] + assert json["role_name"] == role_data["role_name"] + assert json["role_description"] == role_data["role_description"] + assert json["role_level"] == role_data["role_level"] + assert json["id"] is not None + assert json["created_at"] is not None + assert json["updated_at"] is not None + + @pytest.mark.asyncio + async def test_create_role_fail_duplicate_role_key(self, authentication_web: AuthenticationWeb): + """Test creating a role fails when role_key is duplicated.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_dup_{suffix}", + "role_name": f"Test Role DupKey {suffix}", + "role_description": "desc", + "role_level": 1 + } + await authentication_web.create_role(role_data) + role_data2 = { + "role_key": f"test_role_key_dup_{suffix}", + "role_name": f"Test Role DupKey2 {suffix}", + "role_description": "desc2", + "role_level": 2 + } + response = await authentication_web.create_role(role_data2) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_role_fail_duplicate_role_name(self, authentication_web: AuthenticationWeb): + """Test creating a role fails when role_name is duplicated.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_dupname1_{suffix}", + "role_name": f"Test Role DupName {suffix}", + "role_description": "desc", + "role_level": 1 + } + await authentication_web.create_role(role_data) + role_data2 = { + "role_key": f"test_role_key_dupname2_{suffix}", + "role_name": f"Test Role DupName {suffix}", + "role_description": "desc2", + "role_level": 2 + } + response = await authentication_web.create_role(role_data2) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_role_fail_empty_role_key(self, authentication_web: AuthenticationWeb): + """Test creating a role fails when role_key is empty.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": "", + "role_name": f"Test Role EmptyKey {suffix}", + "role_description": "desc", + "role_level": 1 + } + response = await authentication_web.create_role(role_data) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_role_fail_empty_role_name(self, authentication_web: AuthenticationWeb): + """Test creating a role fails when role_name is empty.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_emptyname_{suffix}", + "role_name": "", + "role_description": "desc", + "role_level": 1 + } + response = await authentication_web.create_role(role_data) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_role_success_empty_description(self, authentication_web: AuthenticationWeb): + """Test creating a role successfully when role_description is None (optional field).""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_emptydesc_{suffix}", + "role_name": f"Test Role EmptyDesc {suffix}", + "role_description": None, + "role_level": 1 + } + response = await authentication_web.create_role(role_data) + assert response.status_code == 200 + json = response.json() + assert json["role_key"] == role_data["role_key"] + assert json["role_name"] == role_data["role_name"] + assert json["role_description"] is None or json["role_description"] == "" + assert json["role_level"] == role_data["role_level"] + + @pytest.mark.asyncio + async def test_create_role_fail_by_non_admin(self, authentication_web_of_temp_user1: AuthenticationWeb): + """Test creating a role fails by non-admin user (no permission).""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_nonadmin_{suffix}", + "role_name": f"Test Role NonAdmin {suffix}", + "role_description": "desc", + "role_level": 1 + } + response = await authentication_web_of_temp_user1.create_role(role_data) + assert response.status_code == 403 or response.status_code == 401 + + @pytest.mark.asyncio + async def test_create_role_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test creating a role succeeds after granting admin role to a temporary user and re-login.""" + # Create a temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + response1 = await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to create role + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_tempadmin_{suffix}", + "role_name": f"Test Role TempAdmin {suffix}", + "role_description": "desc", + "role_level": 1 + } + response = await temp_authentication_web.create_role(role_data) + assert response.status_code == 200 + + + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/api_tests/role/test_delete_role.py b/tests/api_tests/role/test_delete_role.py new file mode 100644 index 0000000..a94ce2c --- /dev/null +++ b/tests/api_tests/role/test_delete_role.py @@ -0,0 +1,91 @@ +import pytest +import random + +from backend.models.permission.constants import DefaultRole, DefaultRoleEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestDeleteRole: + + @pytest.mark.asyncio + async def test_delete_role_success(self, authentication_web: AuthenticationWeb): + """Test deleting a role successfully.""" + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"delrole_{suffix}", + "role_name": f"delrole_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + resp = await authentication_web.delete_role(role_data={"role_id": role_id}) + assert resp.status_code == 200 + assert resp.json()["success"] is True + + @pytest.mark.asyncio + async def test_delete_role_fail_not_found(self, authentication_web: AuthenticationWeb): + """Test deleting a role fails when role_id does not exist.""" + resp = await authentication_web.delete_role(role_data={"role_id": "000000000000000000000000"}) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_delete_default_role_fail(self, authentication_web: AuthenticationWeb): + """Test deleting a default role fails. Default role cannot be deleted.""" + # Query a default role + resp = await authentication_web.query_roles( + params={"page": 1, "page_size": 2, "role_key": DefaultRoleEnum.ADMIN.value.role_key}) + json = resp.json() + default_role_id = json["items"][0]["id"] + resp = await authentication_web.delete_role(role_data={"role_id": default_role_id}) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_delete_role_fail_by_non_admin(self, authentication_web: AuthenticationWeb, authentication_web_of_temp_user1: AuthenticationWeb): + """Test deleting a role fails by non-admin user (no permission).""" + # Create a role as admin + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"delrole_nonadmin_{suffix}", + "role_name": f"delrole_nonadmin_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + # Try to delete as temp user + resp = await authentication_web_of_temp_user1.delete_role(role_data={"role_id": role_id}) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_delete_role_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test deleting a role succeeds after granting admin role to a temporary user and re-login.""" + + # Create a temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + + # Create a role as admin + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"delrole_tempadmin_{suffix}", + "role_name": f"delrole_tempadmin_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": DefaultRoleEnum.ADMIN.value.role_key}) + admin_role_id = resp.json()["items"][0]["id"] + response1 = await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to delete as temp user + resp = await temp_authentication_web.delete_role(role_data={"role_id": role_id}) + assert resp.status_code == 200 + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/api_tests/role/test_query_role.py b/tests/api_tests/role/test_query_role.py new file mode 100644 index 0000000..574b138 --- /dev/null +++ b/tests/api_tests/role/test_query_role.py @@ -0,0 +1,58 @@ +import random + +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +class TestQueryRole: + + @pytest.mark.asyncio + async def test_query_all_roles(self, authentication_web: AuthenticationWeb): + """Test querying all roles returns a list.""" + resp = await authentication_web.query_roles(params={}) + assert resp.status_code == 200 + json = resp.json() + assert "items" in json + assert "total" in json + + @pytest.mark.asyncio + async def test_query_roles_by_key(self, authentication_web: AuthenticationWeb): + """Test querying roles by role_key with fuzzy search.""" + suffix = str(random.randint(10000, 99999)) + await authentication_web.create_role({ + "role_key": f"querykey_{suffix}", + "role_name": f"querykey_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + resp = await authentication_web.query_roles(params={"role_key": f"querykey_{suffix}"}) + assert resp.status_code == 200 + json = resp.json() + assert any(f"querykey_{suffix}" in item["role_key"] for item in json["items"]) + + @pytest.mark.asyncio + async def test_query_roles_by_name(self, authentication_web: AuthenticationWeb): + """Test querying roles by role_name with fuzzy search.""" + suffix = str(random.randint(10000, 99999)) + await authentication_web.create_role({ + "role_key": f"queryname_{suffix}", + "role_name": f"queryname_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + resp = await authentication_web.query_roles(params={"role_name": f"queryname_{suffix}"}) + assert resp.status_code == 200 + json = resp.json() + assert any(f"queryname_{suffix}" in item["role_name"] for item in json["items"]) + + @pytest.mark.asyncio + async def test_query_roles_pagination(self, authentication_web: AuthenticationWeb): + """Test querying roles with pagination.""" + resp = await authentication_web.query_roles(params={"page": 1, "page_size": 2}) + assert resp.status_code == 200 + json = resp.json() + assert "items" in json + assert "total" in json + assert "page" in json + assert "page_size" in json diff --git a/tests/api_tests/role/test_update_role.py b/tests/api_tests/role/test_update_role.py new file mode 100644 index 0000000..de2421f --- /dev/null +++ b/tests/api_tests/role/test_update_role.py @@ -0,0 +1,233 @@ +import pytest +import random + +from backend.models.permission.constants import DefaultRoleEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestUpdateRole: + + @pytest.mark.asyncio + async def test_update_role_success(self, authentication_web: AuthenticationWeb): + """Test updating a role successfully with valid and unique fields.""" + suffix = str(random.randint(10000, 99999)) + # create firstly + role_data = { + "role_key": f"update_role_key_{suffix}", + "role_name": f"Update Role {suffix}", + "role_description": "desc", + "role_level": 1 + } + create_resp = await authentication_web.create_role(role_data) + role_id = create_resp.json()["id"] + # update + update_data = { + "role_id": role_id, + "role_key": f"update_role_key_{suffix}_new", + "role_name": f"Update Role {suffix} New", + "role_description": "desc new", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 200 + json = resp.json() + assert json["role_key"] == update_data["role_key"] + assert json["role_name"] == update_data["role_name"] + assert json["role_description"] == update_data["role_description"] + assert json["role_level"] == update_data["role_level"] + + @pytest.mark.asyncio + async def test_update_role_fail_not_found(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_id does not exist.""" + suffix = str(random.randint(10000, 99999)) + update_data = { + "role_id": "000000000000000000000000", # 不存在的ObjectId + "role_key": f"notfound_key_{suffix}", + "role_name": f"NotFound Role {suffix}", + "role_description": "desc", + "role_level": 1 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_role_fail_duplicate_key(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_key is duplicated.""" + suffix = str(random.randint(10000, 99999)) + # create two roles + role1 = await authentication_web.create_role({ + "role_key": f"dupkey1_{suffix}", + "role_name": f"dupkey1_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role2 = await authentication_web.create_role({ + "role_key": f"dupkey2_{suffix}", + "role_name": f"dupkey2_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role2_id = role2.json()["id"] + # modify role_key + update_data = { + "role_id": role2_id, + "role_key": f"dupkey1_{suffix}", + "role_name": f"dupkey2_{suffix}_new", + "role_description": "desc", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_role_fail_duplicate_name(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_name is duplicated.""" + suffix = str(random.randint(10000, 99999)) + # create two roles + role1 = await authentication_web.create_role({ + "role_key": f"dupname1_{suffix}", + "role_name": f"dupname1_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role2 = await authentication_web.create_role({ + "role_key": f"dupname2_{suffix}", + "role_name": f"dupname2_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role2_id = role2.json()["id"] + # modify role name + update_data = { + "role_id": role2_id, + "role_key": f"dupname2_{suffix}_new", + "role_name": f"dupname1_{suffix}", + "role_description": "desc", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_role_fail_empty_key(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_key is empty.""" + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"emptykey_{suffix}", + "role_name": f"emptykey_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + update_data = { + "role_id": role_id, + "role_key": "", + "role_name": f"emptykey_{suffix}_new", + "role_description": "desc", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_role_fail_empty_name(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_name is empty.""" + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"emptyname_{suffix}", + "role_name": f"emptyname_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + update_data = { + "role_id": role_id, + "role_key": f"emptyname_{suffix}_new", + "role_name": "", + "role_description": "desc", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_default_role_fail(self, authentication_web: AuthenticationWeb): + """Test updating a default role fails. Default role cannot be updated.""" + suffix = str(random.randint(10000, 99999)) + # Query a default role + resp = await authentication_web.query_roles( + params={"page": 1, "page_size": 2, "role_key": DefaultRoleEnum.ADMIN.value.role_key}) + json = resp.json() + default_role = json["items"][0] + resp = await authentication_web.update_role(role_data={ + "role_id": default_role["id"], + "role_key": f"{default_role['role_key']}_{suffix}_update", + "role_name": f"{default_role['role_name']}_{suffix}_update", + "role_description": "desc", + "role_level": 2 + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_role_fail_by_non_admin(self, authentication_web: AuthenticationWeb, authentication_web_of_temp_user1: AuthenticationWeb): + """Test updating a role fails by non-admin user (no permission).""" + # Create a role as admin + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"updaterole_nonadmin_{suffix}", + "role_name": f"updaterole_nonadmin_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + update_data = { + "role_id": role_id, + "role_key": f"updaterole_nonadmin_{suffix}_new", + "role_name": f"updaterole_nonadmin_{suffix}_new", + "role_description": "desc new", + "role_level": 2 + } + resp = await authentication_web_of_temp_user1.update_role(role_data=update_data) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_update_role_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test updating a role succeeds after granting admin role to a temporary user and re-login.""" + # Create a temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + + # Create a role as admin + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"updaterole_tempadmin_{suffix}", + "role_name": f"updaterole_tempadmin_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to update as temp user + update_data = { + "role_id": role_id, + "role_key": f"updaterole_tempadmin_{suffix}_new", + "role_name": f"updaterole_tempadmin_{suffix}_new", + "role_description": "desc new", + "role_level": 2 + } + resp = await temp_authentication_web.update_role(role_data=update_data) + assert resp.status_code == 200 + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/api_tests/siginin/${CODEBASE_ROOT}/log/$APP_NAME-activity.log b/tests/api_tests/siginin/${CODEBASE_ROOT}/log/$APP_NAME-activity.log new file mode 100644 index 0000000..9fcdd7f --- /dev/null +++ b/tests/api_tests/siginin/${CODEBASE_ROOT}/log/$APP_NAME-activity.log @@ -0,0 +1,36 @@ +{"level": "info", "timestamp": 1753414164073, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414164073, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 199000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753414268137, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414268137, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 241000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753414313105, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414313105, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 184000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753414574161, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414574161, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 235000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753414637378, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414637379, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 197000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753414641447, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414641448, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 189000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753414668377, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414668377, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 388000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 100, in get_user_id_by_email\n user_email = await UserEmailDoc.find(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/beanie/odm/interfaces/find.py\", line 248, in find\n return cls.find_many(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/beanie/odm/interfaces/find.py\", line 180, in find_many\n args = cls._add_class_id_filter(args, with_children)\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/beanie/odm/interfaces/find.py\", line 415, in _add_class_id_filter\n and cls._inheritance_inited\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: _inheritance_inited\n"]} +{"level": "info", "timestamp": 1753414683787, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414683787, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 198000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753414772065, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414772065, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 218000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753414789232, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414789233, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 224000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.w == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: w\n"]} +{"level": "info", "timestamp": 1753414977338, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753414977339, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 236000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753415052188, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753415052188, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 217000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753415093765, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753415093766, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 237000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 100, in get_user_id_by_email\n user_email = await UserEmailDoc.find(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/beanie/odm/interfaces/find.py\", line 248, in find\n return cls.find_many(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/beanie/odm/interfaces/find.py\", line 180, in find_many\n args = cls._add_class_id_filter(args, with_children)\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/beanie/odm/interfaces/find.py\", line 404, in _add_class_id_filter\n if any(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/beanie/odm/interfaces/find.py\", line 408, in \n if isinstance(a, Iterable) and cls.get_settings().class_id in a\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/beanie/odm/documents.py\", line 1023, in get_settings\n raise CollectionWasNotInitialized\n", "beanie.exceptions.CollectionWasNotInitialized\n"]} +{"level": "info", "timestamp": 1753415111085, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753415111085, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 310000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753415200455, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753415200455, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 226000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753415522833, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753415522834, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 372000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753415607632, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753415607633, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 207000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} +{"level": "info", "timestamp": 1753416659986, "text": "Enter:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753416659986, "text": "Exception:signin_with_email_and_password of ../../../backend/application/signin_hub.py", "fields": {"function": "signin_with_email_and_password", "file": "../../../backend/application/signin_hub.py", "excution_time_in_ns": 216000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "$APP_NAME-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/application/signin_hub.py\", line 66, in signin_with_email_and_password\n return await self.signin_manager.signin_with_email_and_password(\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/business/signin_manager.py\", line 134, in signin_with_email_and_password\n user_id = await self.user_auth_service.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/auth/user_auth_service.py\", line 15, in get_user_id_by_email\n return await self.user_auth_handler.get_user_id_by_email(email)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/infra/auth/user_auth_handler.py\", line 101, in get_user_id_by_email\n UserEmailDoc.email == email.lower()\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/site-packages/pydantic/_internal/_model_construction.py\", line 262, in __getattr__\n raise AttributeError(item)\n", "AttributeError: email\n"]} diff --git a/tests/api_tests/siginin/README.md b/tests/api_tests/siginin/README.md new file mode 100644 index 0000000..1dcc038 --- /dev/null +++ b/tests/api_tests/siginin/README.md @@ -0,0 +1,37 @@ +# Signin API Test Report + +## How to Run the Tests + +**Run all signin API tests:** +```bash +pytest --tb=short tests/api_tests/siginin/ +``` + +--- + +## Test Results Summary + +- **Total tests collected:** 1 +- **All tests passed.** +- **Warnings:** + - Deprecation warning from Pydantic (upgrade recommended for future compatibility). + +--- + +## Test Case Explanations + +### test_signin_with_email_and_password.py +- **test_sign_in_with_email_and_password** + This test verifies the email and password sign-in flow: + - Calls the login API with valid credentials. + - Asserts that the response contains a valid access token, refresh token, expiration, identity, role names, and user permissions. + - Decodes the JWT access token and checks that the payload contains the expected subject fields (id, role_names, user_permissions). + +--- + +## Summary + +- This test ensures that the email/password sign-in API returns all required authentication and user information fields, and that the JWT token is correctly structured. +- If you need to add more signin scenarios, add new test cases to this directory and re-run the tests. + +--- \ No newline at end of file diff --git a/tests/api_tests/siginin/__init__.py b/tests/api_tests/siginin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api_tests/siginin/config.py b/tests/api_tests/siginin/config.py new file mode 100644 index 0000000..806c60c --- /dev/null +++ b/tests/api_tests/siginin/config.py @@ -0,0 +1,2 @@ +JWT_SECRET_KEY = "ea84edf152976b2fcec12b78aa8e45bc26a5cf0ef61bf16f5c317ae33b3fd8b0" +JWT_ALGORITHM = "HS256" diff --git a/tests/api_tests/siginin/conftest.py b/tests/api_tests/siginin/conftest.py new file mode 100644 index 0000000..5178696 --- /dev/null +++ b/tests/api_tests/siginin/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture +def authentication_web() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web diff --git a/tests/api_tests/siginin/log/authentication-application-activity.log b/tests/api_tests/siginin/log/authentication-application-activity.log new file mode 100644 index 0000000..e69de29 diff --git a/tests/api_tests/siginin/test_signin_with_email_and_password.py b/tests/api_tests/siginin/test_signin_with_email_and_password.py new file mode 100644 index 0000000..90e0d27 --- /dev/null +++ b/tests/api_tests/siginin/test_signin_with_email_and_password.py @@ -0,0 +1,30 @@ +import jwt +import pytest + +from tests.api_tests.siginin import config +from tests.base.authentication_web import AuthenticationWeb + + +class TestSignInWithEmailAndPassword: + + @pytest.mark.asyncio + async def test_sign_in_with_email_and_password(self, authentication_web: AuthenticationWeb): + response = authentication_web.do_login() + assert response.status_code == 200 + json = response.json() + assert json["access_token"] is not None, "access_token should not be None" + assert json["refresh_token"] is not None, "refresh_token should not be None" + assert json["expires_in"] is not None, "expires_in should not be None" + assert json["identity"] is not None, "identity should not be None" + assert json["role_names"] is not None, "role_names should not be None" + assert json["user_permissions"] is not None, "user_permissions should not be None" + + payload = jwt.decode(json["access_token"], config.JWT_SECRET_KEY, algorithms=[config.JWT_ALGORITHM]) + assert payload["subject"] is not None, "subject should not be None" + assert payload["subject"]["id"] is not None, "subject.id should not be None" + assert payload["subject"]["role_names"] is not None, "subject.role_names should not be None" + assert payload["subject"]["user_permissions"] is not None, "subject.user_permissions should not be None" + print(payload) + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/api_tests/user/README.md b/tests/api_tests/user/README.md new file mode 100644 index 0000000..55f3c93 --- /dev/null +++ b/tests/api_tests/user/README.md @@ -0,0 +1,45 @@ +# User API Test Report + +## How to Run the Tests + +**Run all user API tests:** +```bash +pytest --tb=short tests/api_tests/user/ +``` + +--- + +## Test Results Summary + +- **Total tests collected:** 6 +- **All tests passed.** +- **Warnings:** + - Deprecation warnings from Pydantic/Beanie (upgrade recommended for future compatibility). + +--- + +## Test Case Explanations + +### test_assign_roles.py +- **test_assign_roles_success_by_admin** + Admin user can assign a role to a user successfully. +- **test_assign_roles_fail_by_non_admin** + Non-admin user cannot assign roles to other users (permission denied). +- **test_assign_roles_fail_role_not_found** + Assigning a non-existent role to a user fails. +- **test_assign_roles_fail_empty_role_ids** + Assigning with an empty role list fails. +- **test_assign_roles_fail_empty_user_id** + Assigning roles with an empty user ID fails. +- **test_assign_roles_remove_duplicates** + Assigning duplicate role IDs results in de-duplication; the user ends up with a single instance of the role. + +--- + +## Summary + +- These tests ensure that only admin users can assign roles to users, and that the system properly handles invalid input and duplicate assignments. +- Each test case is designed to verify both positive and negative scenarios, including permission checks and input validation. +- If you need to add more user management scenarios, add new test cases to this directory and re-run the tests. + +--- \ No newline at end of file diff --git a/tests/api_tests/user/__init__.py b/tests/api_tests/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api_tests/user/conftest.py b/tests/api_tests/user/conftest.py new file mode 100644 index 0000000..b96c630 --- /dev/null +++ b/tests/api_tests/user/conftest.py @@ -0,0 +1,21 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture(scope="session") +def authentication_web() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web + + +@pytest.fixture(scope="session") +def authentication_web_of_temp_user1() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + user = authentication_web.create_temporary_user() + authentication_web.user_email = user["email"] + authentication_web.password = user["password"] + authentication_web.user_id = user["user_id"] + authentication_web.login() + return authentication_web diff --git a/tests/api_tests/user/test_assign_roles.py b/tests/api_tests/user/test_assign_roles.py new file mode 100644 index 0000000..18c7e30 --- /dev/null +++ b/tests/api_tests/user/test_assign_roles.py @@ -0,0 +1,100 @@ +import pytest +import random +from backend.models.permission.constants import DefaultRoleEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestAssignRolesToUser: + @pytest.mark.asyncio + async def test_assign_roles_success_by_admin(self, authentication_web: AuthenticationWeb): + """Test assigning roles to a user successfully by admin user.""" + # Create a temporary user + temp_user = authentication_web.create_temporary_user() + # Create a new role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignrole_role_{suffix}", + "role_name": f"AssignRole Role {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + # Assign role to user + resp = await authentication_web.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": [role_id] + }) + assert resp.status_code == 200 + json = resp.json() + assert json["user_id"] == temp_user["user_id"] + assert json["role_ids"] == [role_id] + + @pytest.mark.asyncio + async def test_assign_roles_fail_by_non_admin(self, authentication_web_of_temp_user1: AuthenticationWeb): + """Test assigning roles to a user fails by non-admin user (no permission).""" + # Create another temporary user + temp_user = authentication_web_of_temp_user1.create_temporary_user() + # Query default admin role + resp = await authentication_web_of_temp_user1.query_roles({"role_key": DefaultRoleEnum.ADMIN.value.role_key}) + admin_role_id = resp.json()["items"][0]["id"] + # Try to assign admin role to another user + resp = await authentication_web_of_temp_user1.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": [admin_role_id] + }) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_assign_roles_fail_role_not_found(self, authentication_web: AuthenticationWeb): + """Test assigning roles fails when role_id does not exist.""" + # Create a temporary user + temp_user = authentication_web.create_temporary_user() + # Try to assign non-existent role + resp = await authentication_web.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": ["000000000000000000000000"] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_roles_fail_empty_role_ids(self, authentication_web: AuthenticationWeb): + """Test assigning roles fails when role_ids is empty.""" + # Create a temporary user + temp_user = authentication_web.create_temporary_user() + resp = await authentication_web.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": [] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_roles_fail_empty_user_id(self, authentication_web: AuthenticationWeb): + """Test assigning roles fails when user_id is empty.""" + # Query default admin role + resp = await authentication_web.query_roles({"role_key": DefaultRoleEnum.ADMIN.value.role_key}) + admin_role_id = resp.json()["items"][0]["id"] + resp = await authentication_web.assign_roles_to_user({ + "user_id": "", "role_ids": [admin_role_id] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_roles_remove_duplicates(self, authentication_web: AuthenticationWeb): + """Test assigning roles with duplicate role_ids removes duplicates.""" + # Create a temporary user + temp_user = authentication_web.create_temporary_user() + # Create a new role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignrole_role_dup_{suffix}", + "role_name": f"AssignRole RoleDup {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + # Assign duplicate role_ids + resp = await authentication_web.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": [role_id, role_id, role_id] + }) + assert resp.status_code == 200 + json = resp.json() + assert json["role_ids"] == [role_id] + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/base/__init__.py b/tests/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/base/authentication_web.py b/tests/base/authentication_web.py new file mode 100644 index 0000000..e3cfc9a --- /dev/null +++ b/tests/base/authentication_web.py @@ -0,0 +1,157 @@ +import asyncio + +import httpx +from typing import Optional +from tests.base.config import USER_EMAIL, USER_PASSWORD, BASE_URL +from tests.util.temporary_email import * + + +class AuthenticationWeb: + def __init__(self, user_email: str = USER_EMAIL, password: str = USER_PASSWORD, base_url: str = BASE_URL): + self.user_email = user_email + self.password = password + self.user_id = None + self.base_url = base_url + self.token: Optional[str] = None + + def create_temporary_user(self) -> dict[str, str]: + """Create a temporary user.""" + # generate temporary user email + email = generate_email() + print("temporary user email:", email) + # call try-signin-with-email api + response1 = self.try_signin_with_email(params={"email": email, "host": self.base_url}) + print("try_signin_with_email", response1.json()) + # query auth code + auth_code = get_auth_code(email) + print("temporary user auth code:", auth_code) + response2 = self.signin_with_email_and_code( + params={"email": email, "code": auth_code, "host": self.base_url}) + print("signin_with_email_and_code", response2.json()) + access_token = response2.json()["access_token"] + + response3 = self.update_new_user_flid(token=access_token, params={'flid': response2.json()['flid']}) + print("update_new_user_flid", response3.json()) + + password = "Kdwy12#$" + # set password + response4 = self.update_user_password(token=access_token, params={ + 'password': password, + 'password2': password + }) + print("update_user_password", response4.json()) + return { + "email": email, + "password": password, + "user_id": response2.json()["identity"] + } + + def update_new_user_flid(self, params: dict, token: str = None): + """Update the user's FLID.""" + if token is None: + token = self.token + headers = {"Authorization": f"Bearer {token}"} + with httpx.Client(base_url=self.base_url) as client: + resp = client.request("POST", "/api/auth/signin/update-new-user-flid", headers=headers, json=params) + return resp + + def update_user_password(self, params: dict, token: str = None): + """Update the user's password.""" + if token is None: + token = self.token + headers = {"Authorization": f"Bearer {token}"} + with httpx.Client(base_url=self.base_url) as client: + resp = client.request("POST", "/api/auth/signin/update-user-password", headers=headers, json=params) + return resp + + def try_signin_with_email(self, params): + """try signin with email.""" + return self.request_sync("POST", "/api/auth/signin/try-signin-with-email", json=params) + + def signin_with_email_and_code(self, params): + """try signin with email and code.""" + return self.request_sync("POST", "/api/auth/signin/signin-with-email-and-code", json=params) + + def login(self): + """Login and store JWT token""" + with httpx.Client(base_url=self.base_url) as client: + resp = self.do_login(self.user_email, self.password) + self.token = resp.json()["access_token"] + return resp + + def do_login(self, user_email: str = USER_EMAIL, password: str = USER_PASSWORD): + """Login and store JWT token""" + with httpx.Client(base_url=self.base_url) as client: + resp = client.post("/api/auth/signin/signin-with-email-and-password", json={ + "email": user_email, + "password": password + }) + return resp + + def request_sync(self, method: str, url: str, **kwargs): + """Send authenticated request""" + headers = kwargs.pop("headers", {}) + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + with httpx.Client(base_url=self.base_url) as client: + resp = client.request(method, url, headers=headers, **kwargs) + return resp + + async def request(self, method: str, url: str, **kwargs): + """Send authenticated request""" + headers = kwargs.pop("headers", {}) + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + async with httpx.AsyncClient(base_url=self.base_url) as client: + resp = await client.request(method, url, headers=headers, **kwargs) + return resp + + async def create_role(self, role_data: dict): + """Create a new role via API""" + return await self.request("POST", "/api/auth/role/create", json=role_data) + + async def delete_role(self, role_data: dict): + """Delete role via API""" + return await self.request("POST", "/api/auth/role/delete", json=role_data) + + async def update_role(self, role_data: dict): + """Update role via API""" + return await self.request("POST", "/api/auth/role/update", json=role_data) + + async def query_roles(self, params: dict = None): + """Query roles via API""" + if params is None: + params = {} + return await self.request("POST", "/api/auth/role/query", json=params) + + async def create_permission(self, perm_data: dict): + """Create a new permission via API""" + return await self.request("POST", "/api/auth/permission/create", json=perm_data) + + async def delete_permission(self, perm_data: dict): + """Delete a permission via API""" + return await self.request("POST", "/api/auth/permission/delete", json=perm_data) + + async def update_permission(self, perm_data: dict): + """Update a permission via API""" + return await self.request("POST", "/api/auth/permission/update", json=perm_data) + + async def query_permissions(self, params: dict = None): + """Query permissions via API""" + if params is None: + params = {} + return await self.request("POST", "/api/auth/permission/query", json=params) + + async def assign_permissions_to_role(self, data: dict): + """Assign permissions to a role via API""" + return await self.request("POST", "/api/auth/role/assign-permissions", json=data) + + async def assign_roles_to_user(self, data: dict): + """Assign roles to a user via API""" + return await self.request("POST", "/api/auth/user/assign-roles", json=data) + + +if __name__ == '__main__': + authentication = AuthenticationWeb() + user = authentication.create_temporary_user() + print(user) diff --git a/tests/base/config.py b/tests/base/config.py new file mode 100644 index 0000000..f77bcab --- /dev/null +++ b/tests/base/config.py @@ -0,0 +1,5 @@ +# user with admin role +USER_EMAIL = "XXXX" +USER_PASSWORD = "XXXX" +# authentication base url +BASE_URL = "http://localhost:8103" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5178696 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture +def authentication_web() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web diff --git a/tests/role_manage_coverage_report.md b/tests/role_manage_coverage_report.md new file mode 100644 index 0000000..fd3b0d4 --- /dev/null +++ b/tests/role_manage_coverage_report.md @@ -0,0 +1,28 @@ +# Test Coverage Report (backend modules only) + +--- + +## Coverage Table + +| File Name | Statements | Missed | Coverage | +|------------------------------------------------------------------|------------|--------|----------| +| backend/infra/permission/permission_handler.py | 55 | 0 | 100% | +| backend/infra/permission/role_handler.py | 71 | 0 | 100% | +| backend/infra/permission/user_role_handler.py | 39 | 7 | 82% | +| backend/services/permission/permission_service.py | 20 | 0 | 100% | +| backend/services/permission/role_service.py | 24 | 0 | 100% | +| backend/services/user/user_management_service.py | 39 | 0 | 100% | + +--- + +## Summary + +This test report only includes the test coverage of functions related to role management. + +See the integration tests: +- tests/api_tests/permission/README.md +- tests/api_tests/role/README.md +- tests/api_tests/user/README.md +## TODO + +Add tests for the previous functions. diff --git a/tests/unit_tests/__init__.py b/tests/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/__init__.py b/tests/unit_tests/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/infra/__init__.py b/tests/unit_tests/backend/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/infra/permission/__init__.py b/tests/unit_tests/backend/infra/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/infra/permission/permission_handler/__init__.py b/tests/unit_tests/backend/infra/permission/permission_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/infra/permission/permission_handler/test_permission_handler.py b/tests/unit_tests/backend/infra/permission/permission_handler/test_permission_handler.py new file mode 100644 index 0000000..49ff06b --- /dev/null +++ b/tests/unit_tests/backend/infra/permission/permission_handler/test_permission_handler.py @@ -0,0 +1,137 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.exceptions import RequestValidationError +from backend.infra.permission.permission_handler import PermissionHandler +from backend.models.permission.models import PermissionDoc, RoleDoc +from bson import ObjectId + +@pytest.fixture(autouse=True) +def mock_db(): + with patch('backend.infra.permission.permission_handler.PermissionDoc') as MockPermissionDoc, \ + patch('backend.infra.permission.permission_handler.RoleDoc') as MockRoleDoc: + yield MockPermissionDoc, MockRoleDoc + +@pytest.mark.asyncio +class TestPermissionHandler: + @pytest.fixture(autouse=True) + def setup(self, mock_db): + self.MockPermissionDoc, self.MockRoleDoc = mock_db + self.handler = PermissionHandler() + + async def test_create_permission_success(self): + # Test creating a permission successfully + self.MockPermissionDoc.find_one = AsyncMock(side_effect=[None, None]) + mock_doc = MagicMock(spec=PermissionDoc) + self.MockPermissionDoc.return_value = mock_doc + mock_doc.insert = AsyncMock() + result = await self.handler.create_permission('key', 'name', 'desc') + assert result == mock_doc + mock_doc.insert.assert_awaited_once() + + async def test_create_permission_missing_key_or_name(self): + # Test missing permission_key or permission_name raises validation error + with pytest.raises(RequestValidationError): + await self.handler.create_permission('', 'name', 'desc') + with pytest.raises(RequestValidationError): + await self.handler.create_permission('key', '', 'desc') + + async def test_create_permission_duplicate(self): + # Test duplicate permission_key or permission_name raises validation error + self.MockPermissionDoc.find_one = AsyncMock(side_effect=[MagicMock(), None]) + with pytest.raises(RequestValidationError): + await self.handler.create_permission('key', 'name', 'desc') + self.MockPermissionDoc.find_one = AsyncMock(side_effect=[None, MagicMock()]) + with pytest.raises(RequestValidationError): + await self.handler.create_permission('key', 'name', 'desc') + + async def test_update_permission_success(self): + # Test updating a permission successfully + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = False + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + self.MockPermissionDoc.find_one = AsyncMock(return_value=None) + mock_doc.save = AsyncMock() + result = await self.handler.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + assert result == mock_doc + mock_doc.save.assert_awaited_once() + + async def test_update_permission_missing_args(self): + # Test missing permission_id, permission_key or permission_name raises validation error + with pytest.raises(RequestValidationError): + await self.handler.update_permission(None, 'key', 'name', 'desc') + with pytest.raises(RequestValidationError): + await self.handler.update_permission('507f1f77bcf86cd799439011', '', 'name', 'desc') + with pytest.raises(RequestValidationError): + await self.handler.update_permission('507f1f77bcf86cd799439011', 'key', '', 'desc') + + async def test_update_permission_not_found(self): + # Test updating a non-existent permission raises validation error + self.MockPermissionDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + + async def test_update_permission_is_default(self): + # Test updating a default permission raises validation error + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = True + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + with pytest.raises(RequestValidationError): + await self.handler.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + + async def test_update_permission_conflict(self): + # Test updating a permission with duplicate key or name raises validation error + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = False + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + self.MockPermissionDoc.find_one = AsyncMock(return_value=MagicMock()) + with pytest.raises(RequestValidationError): + await self.handler.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + + async def test_query_permissions_success(self): + # Test querying permissions returns docs and total + mock_cursor = MagicMock() + mock_cursor.count = AsyncMock(return_value=2) + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = mock_cursor + mock_cursor.to_list = AsyncMock(return_value=['doc1', 'doc2']) + self.MockPermissionDoc.find.return_value = mock_cursor + docs, total = await self.handler.query_permissions('key', 'name', 0, 10) + assert docs == ['doc1', 'doc2'] + assert total == 2 + + async def test_delete_permission_success(self): + # Test deleting a permission successfully + self.MockRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = False + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + mock_doc.delete = AsyncMock() + await self.handler.delete_permission('507f1f77bcf86cd799439011') + mock_doc.delete.assert_awaited_once() + + async def test_delete_permission_missing_id(self): + # Test missing permission_id raises validation error + with pytest.raises(RequestValidationError): + await self.handler.delete_permission(None) + + async def test_delete_permission_referenced_by_role(self): + # Test deleting a permission referenced by a role raises validation error + self.MockRoleDoc.find_one = AsyncMock(return_value=MagicMock()) + with pytest.raises(RequestValidationError): + await self.handler.delete_permission('507f1f77bcf86cd799439011') + + async def test_delete_permission_not_found(self): + # Test deleting a non-existent permission raises validation error + self.MockRoleDoc.find_one = AsyncMock(return_value=None) + self.MockPermissionDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.delete_permission('507f1f77bcf86cd799439011') + + async def test_delete_permission_is_default(self): + # Test deleting a default permission raises validation error + self.MockRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = True + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + with pytest.raises(RequestValidationError): + await self.handler.delete_permission('507f1f77bcf86cd799439011') \ No newline at end of file diff --git a/tests/unit_tests/backend/infra/permission/role_handler/__init__.py b/tests/unit_tests/backend/infra/permission/role_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/infra/permission/role_handler/test_role_handler.py b/tests/unit_tests/backend/infra/permission/role_handler/test_role_handler.py new file mode 100644 index 0000000..3f396ef --- /dev/null +++ b/tests/unit_tests/backend/infra/permission/role_handler/test_role_handler.py @@ -0,0 +1,169 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.exceptions import RequestValidationError +from backend.infra.permission.role_handler import RoleHandler +from backend.models.permission.models import RoleDoc, PermissionDoc, UserRoleDoc +from bson import ObjectId + +@pytest.fixture(autouse=True) +def mock_db(): + with patch('backend.infra.permission.role_handler.RoleDoc') as MockRoleDoc, \ + patch('backend.infra.permission.role_handler.PermissionDoc') as MockPermissionDoc, \ + patch('backend.infra.permission.role_handler.UserRoleDoc') as MockUserRoleDoc: + yield MockRoleDoc, MockPermissionDoc, MockUserRoleDoc + +@pytest.mark.asyncio +class TestRoleHandler: + @pytest.fixture(autouse=True) + def setup(self, mock_db): + self.MockRoleDoc, self.MockPermissionDoc, self.MockUserRoleDoc = mock_db + self.handler = RoleHandler() + + async def test_create_role_success(self): + # Test creating a role successfully + self.MockRoleDoc.find_one = AsyncMock(side_effect=[None, None]) + mock_doc = MagicMock(spec=RoleDoc) + self.MockRoleDoc.return_value = mock_doc + mock_doc.insert = AsyncMock() + result = await self.handler.create_role('key', 'name', 'desc', 1) + assert result == mock_doc + mock_doc.insert.assert_awaited_once() + + async def test_create_role_missing_key_or_name(self): + # Test missing role_key or role_name raises validation error + with pytest.raises(RequestValidationError): + await self.handler.create_role('', 'name', 'desc', 1) + with pytest.raises(RequestValidationError): + await self.handler.create_role('key', '', 'desc', 1) + + async def test_create_role_duplicate(self): + # Test duplicate role_key or role_name raises validation error + self.MockRoleDoc.find_one = AsyncMock(side_effect=[MagicMock(), None]) + with pytest.raises(RequestValidationError): + await self.handler.create_role('key', 'name', 'desc', 1) + self.MockRoleDoc.find_one = AsyncMock(side_effect=[None, MagicMock()]) + with pytest.raises(RequestValidationError): + await self.handler.create_role('key', 'name', 'desc', 1) + + async def test_update_role_success(self): + # Test updating a role successfully + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = False + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + self.MockRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc.save = AsyncMock() + result = await self.handler.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + assert result == mock_doc + mock_doc.save.assert_awaited_once() + + async def test_update_role_missing_args(self): + # Test missing role_id, role_key or role_name raises validation error + with pytest.raises(RequestValidationError): + await self.handler.update_role(None, 'key', 'name', 'desc', 1) + with pytest.raises(RequestValidationError): + await self.handler.update_role('507f1f77bcf86cd799439011', '', 'name', 'desc', 1) + with pytest.raises(RequestValidationError): + await self.handler.update_role('507f1f77bcf86cd799439011', 'key', '', 'desc', 1) + + async def test_update_role_not_found(self): + # Test updating a non-existent role raises validation error + self.MockRoleDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + + async def test_update_role_is_default(self): + # Test updating a default role raises validation error + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = True + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + with pytest.raises(RequestValidationError): + await self.handler.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + + async def test_update_role_conflict(self): + # Test updating a role with duplicate key or name raises validation error + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = False + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + self.MockRoleDoc.find_one = AsyncMock(return_value=MagicMock()) + with pytest.raises(RequestValidationError): + await self.handler.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + + async def test_query_roles_success(self): + # Test querying roles returns docs and total + mock_cursor = MagicMock() + mock_cursor.count = AsyncMock(return_value=2) + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = mock_cursor + mock_cursor.to_list = AsyncMock(return_value=['doc1', 'doc2']) + self.MockRoleDoc.find.return_value = mock_cursor + docs, total = await self.handler.query_roles('key', 'name', 0, 10) + assert docs == ['doc1', 'doc2'] + assert total == 2 + + async def test_assign_permissions_to_role_success(self): + # Test assigning permissions to a role successfully + mock_doc = MagicMock(spec=RoleDoc) + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + self.MockPermissionDoc.get = AsyncMock(return_value=MagicMock()) + mock_doc.save = AsyncMock() + result = await self.handler.assign_permissions_to_role('507f1f77bcf86cd799439011', ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439011']) + assert result == mock_doc + mock_doc.save.assert_awaited_once() + + async def test_assign_permissions_to_role_missing_args(self): + # Test missing role_id or permission_ids raises validation error + with pytest.raises(RequestValidationError): + await self.handler.assign_permissions_to_role(None, ['pid1']) + with pytest.raises(RequestValidationError): + await self.handler.assign_permissions_to_role('507f1f77bcf86cd799439011', None) + + async def test_assign_permissions_to_role_role_not_found(self): + # Test assigning permissions to a non-existent role raises validation error + self.MockRoleDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.assign_permissions_to_role('507f1f77bcf86cd799439011', ['507f1f77bcf86cd799439011']) + + async def test_assign_permissions_to_role_permission_not_found(self): + # Test assigning a non-existent permission raises validation error + mock_doc = MagicMock(spec=RoleDoc) + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + self.MockPermissionDoc.get = AsyncMock(side_effect=[None, MagicMock()]) + with pytest.raises(RequestValidationError): + await self.handler.assign_permissions_to_role('507f1f77bcf86cd799439011', ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439011']) + + async def test_delete_role_success(self): + # Test deleting a role successfully + self.MockUserRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = False + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + mock_doc.delete = AsyncMock() + await self.handler.delete_role('507f1f77bcf86cd799439011') + mock_doc.delete.assert_awaited_once() + + async def test_delete_role_missing_id(self): + # Test missing role_id raises validation error + with pytest.raises(RequestValidationError): + await self.handler.delete_role(None) + + async def test_delete_role_referenced_by_user(self): + # Test deleting a role referenced by a user raises validation error + self.MockUserRoleDoc.find_one = AsyncMock(return_value=MagicMock()) + with pytest.raises(RequestValidationError): + await self.handler.delete_role('507f1f77bcf86cd799439011') + + async def test_delete_role_not_found(self): + # Test deleting a non-existent role raises validation error + self.MockUserRoleDoc.find_one = AsyncMock(return_value=None) + self.MockRoleDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.delete_role('507f1f77bcf86cd799439011') + + async def test_delete_role_is_default(self): + # Test deleting a default role raises validation error + self.MockUserRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = True + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + with pytest.raises(RequestValidationError): + await self.handler.delete_role('507f1f77bcf86cd799439011') \ No newline at end of file diff --git a/tests/unit_tests/backend/infra/permission/user_role_handler/__init__.py b/tests/unit_tests/backend/infra/permission/user_role_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/infra/permission/user_role_handler/test_user_role_handler.py b/tests/unit_tests/backend/infra/permission/user_role_handler/test_user_role_handler.py new file mode 100644 index 0000000..2dcc04e --- /dev/null +++ b/tests/unit_tests/backend/infra/permission/user_role_handler/test_user_role_handler.py @@ -0,0 +1,43 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from backend.infra.permission.user_role_handler import UserRoleHandler +from backend.models.permission.models import RoleDoc, UserRoleDoc, PermissionDoc + +@pytest.fixture(autouse=True) +def mock_db(): + with patch('backend.infra.permission.user_role_handler.RoleDoc') as MockRoleDoc, \ + patch('backend.infra.permission.user_role_handler.UserRoleDoc') as MockUserRoleDoc, \ + patch('backend.infra.permission.user_role_handler.PermissionDoc') as MockPermissionDoc: + yield MockRoleDoc, MockUserRoleDoc, MockPermissionDoc + +@pytest.mark.asyncio +class TestUserRoleHandler: + @pytest.fixture(autouse=True) + def setup(self, mock_db): + self.MockRoleDoc, self.MockUserRoleDoc, self.MockPermissionDoc = mock_db + self.handler = UserRoleHandler() + + async def test_assign_roles_to_user_success(self): + # Test assigning roles to a user when no UserRoleDoc exists (create new) + self.MockRoleDoc.get = AsyncMock(return_value=MagicMock()) + self.MockUserRoleDoc.find_one = AsyncMock(return_value=None) + mock_user_role = MagicMock(spec=UserRoleDoc) + self.MockUserRoleDoc.return_value = mock_user_role + mock_user_role.insert = AsyncMock() + result = await self.handler.assign_roles_to_user('507f1f77bcf86cd799439011', ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012']) + assert result == mock_user_role + mock_user_role.insert.assert_awaited_once() + + async def test_get_role_and_permission_by_user_id_with_roles_and_permissions(self): + # Test getting roles and permissions when user has roles and permissions + self.MockUserRoleDoc.find_one = AsyncMock(return_value=MagicMock(role_ids=['507f1f77bcf86cd799439010', '507f1f77bcf86cd799439017'])) + mock_role1 = MagicMock(role_name='role1', permission_ids=['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012']) + mock_role2 = MagicMock(role_name='role2', permission_ids=['507f1f77bcf86cd799439014', '507f1f77bcf86cd799439013']) + self.MockRoleDoc.find.return_value.to_list = AsyncMock(return_value=[mock_role1, mock_role2]) + mock_perm1 = MagicMock(permission_key='perm1') + mock_perm2 = MagicMock(permission_key='perm2') + mock_perm3 = MagicMock(permission_key='perm3') + self.MockPermissionDoc.find.return_value.to_list = AsyncMock(return_value=[mock_perm1, mock_perm2, mock_perm3]) + result = await self.handler.get_role_and_permission_by_user_id('uid') + assert set(result[0]) == {'role1', 'role2'} + assert set(result[1]) == {'perm1', 'perm2', 'perm3'} \ No newline at end of file diff --git a/tests/unit_tests/backend/permission/__init__.py b/tests/unit_tests/backend/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/permission/permission_service/__init__.py b/tests/unit_tests/backend/permission/permission_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/permission/permission_service/test_permission_service.py b/tests/unit_tests/backend/permission/permission_service/test_permission_service.py new file mode 100644 index 0000000..2087462 --- /dev/null +++ b/tests/unit_tests/backend/permission/permission_service/test_permission_service.py @@ -0,0 +1,152 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.exceptions import RequestValidationError +from backend.services.permission.permission_service import PermissionService +from backend.models.permission.models import PermissionDoc + +import datetime + +@pytest.fixture +def mock_permission_handler(): + # Fixture to patch PermissionHandler for isolation and mocking + with patch('backend.services.permission.permission_service.PermissionHandler') as MockHandler: + yield MockHandler + +@pytest.mark.asyncio +class TestPermissionService: + @pytest.fixture(autouse=True) + def setup(self, mock_permission_handler): + # Automatically set up a PermissionService with a mocked handler for each test + self.mock_handler = mock_permission_handler.return_value + self.service = PermissionService() + self.service.permission_handler = self.mock_handler + + async def test_create_permission_success(self): + # Test creating a permission successfully returns the expected PermissionDoc + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.create_permission = AsyncMock(return_value=mock_doc) + result = await self.service.create_permission('key', 'name', 'desc') + assert result == mock_doc + self.mock_handler.create_permission.assert_awaited_once_with('key', 'name', 'desc') + + async def test_create_permission_fail(self): + # Test that an exception is raised if the handler fails to create a permission + self.mock_handler.create_permission = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.create_permission('key', 'name', 'desc') + + async def test_create_permission_with_none_description(self): + # Test creating a permission with None as description + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.create_permission = AsyncMock(return_value=mock_doc) + result = await self.service.create_permission('key', 'name', None) + assert result == mock_doc + self.mock_handler.create_permission.assert_awaited_once_with('key', 'name', None) + + async def test_create_permission_with_empty_description(self): + # Test creating a permission with empty string as description + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.create_permission = AsyncMock(return_value=mock_doc) + result = await self.service.create_permission('key', 'name', '') + assert result == mock_doc + self.mock_handler.create_permission.assert_awaited_once_with('key', 'name', '') + + async def test_create_permission_handler_unexpected_exception(self): + # Test handler raises unexpected exception during creation + self.mock_handler.create_permission = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.create_permission('key', 'name', 'desc') + + async def test_update_permission_success(self): + # Test updating a permission successfully returns the expected PermissionDoc + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.update_permission = AsyncMock(return_value=mock_doc) + result = await self.service.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + assert result == mock_doc + self.mock_handler.update_permission.assert_awaited_once() + + async def test_update_permission_fail(self): + # Test that an exception is raised if the handler fails to update a permission + self.mock_handler.update_permission = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + + async def test_update_permission_with_invalid_id(self): + # Test updating a permission with invalid id (empty string) + self.mock_handler.update_permission = AsyncMock(side_effect=RequestValidationError('invalid id')) + with pytest.raises(RequestValidationError): + await self.service.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + + async def test_update_permission_handler_unexpected_exception(self): + # Test handler raises unexpected exception during update + self.mock_handler.update_permission = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + + async def test_update_permission_partial_args(self): + # Test updating a permission with only key provided + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.update_permission = AsyncMock(return_value=mock_doc) + result = await self.service.update_permission('507f1f77bcf86cd799439011', 'key', None, None) + assert result == mock_doc + self.mock_handler.update_permission.assert_awaited_once() + + async def test_query_permissions_success(self): + # Test querying permissions returns a paginated result with correct items and meta + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.dict.return_value = {'permission_key': 'key', 'permission_name': 'name'} + self.mock_handler.query_permissions = AsyncMock(return_value=([mock_doc], 1)) + result = await self.service.query_permissions('key', 'name', 1, 10) + assert result['items'] == [{'permission_key': 'key', 'permission_name': 'name'}] + assert result['total'] == 1 + assert result['page'] == 1 + assert result['page_size'] == 10 + self.mock_handler.query_permissions.assert_awaited_once_with('key', 'name', 0, 10) + + async def test_query_permissions_handler_exception(self): + # Test handler raises exception during query + self.mock_handler.query_permissions = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.query_permissions('key', 'name', 1, 10) + + async def test_query_permissions_empty_result(self): + # Test query returns empty list + self.mock_handler.query_permissions = AsyncMock(return_value=([], 0)) + result = await self.service.query_permissions('key', 'name', 1, 10) + assert result['items'] == [] + assert result['total'] == 0 + assert result['page'] == 1 + assert result['page_size'] == 10 + + async def test_query_permissions_with_none_and_special_chars(self): + # Test query with None and special characters + self.mock_handler.query_permissions = AsyncMock(return_value=([], 0)) + result = await self.service.query_permissions(None, None, 1, 10) + assert result['items'] == [] + result2 = await self.service.query_permissions('!@#$', '', 1, 10) + assert result2['items'] == [] + + @pytest.mark.parametrize('page,page_size', [(0, 10), (1, 0), (0, 0), (-1, 10), (1, -1)]) + async def test_query_permissions_invalid_page(self, page, page_size): + # Test that invalid page or page_size raises a validation error + with pytest.raises(RequestValidationError): + await self.service.query_permissions('key', 'name', page, page_size) + + async def test_delete_permission_success(self): + # Test deleting a permission successfully returns None + self.mock_handler.delete_permission = AsyncMock(return_value=None) + result = await self.service.delete_permission('507f1f77bcf86cd799439011') + assert result is None + self.mock_handler.delete_permission.assert_awaited_once() + + async def test_delete_permission_fail(self): + # Test that an exception is raised if the handler fails to delete a permission + self.mock_handler.delete_permission = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.delete_permission('507f1f77bcf86cd799439011') + + async def test_delete_permission_handler_unexpected_exception(self): + # Test handler raises unexpected exception during delete + self.mock_handler.delete_permission = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.delete_permission('507f1f77bcf86cd799439011') \ No newline at end of file diff --git a/tests/unit_tests/backend/permission/role_service/__init__.py b/tests/unit_tests/backend/permission/role_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/permission/role_service/test_role_service.py b/tests/unit_tests/backend/permission/role_service/test_role_service.py new file mode 100644 index 0000000..77d858e --- /dev/null +++ b/tests/unit_tests/backend/permission/role_service/test_role_service.py @@ -0,0 +1,172 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.exceptions import RequestValidationError +from backend.services.permission.role_service import RoleService +from backend.models.permission.models import RoleDoc + +@pytest.fixture +def mock_role_handler(): + # Fixture to patch RoleHandler for isolation and mocking + with patch('backend.services.permission.role_service.RoleHandler') as MockHandler: + yield MockHandler + +@pytest.mark.asyncio +class TestRoleService: + @pytest.fixture(autouse=True) + def setup(self, mock_role_handler): + # Automatically set up a RoleService with a mocked handler for each test + self.mock_handler = mock_role_handler.return_value + self.service = RoleService() + self.service.role_handler = self.mock_handler + + async def test_create_role_success(self): + # Test creating a role successfully returns the expected RoleDoc + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.create_role = AsyncMock(return_value=mock_doc) + result = await self.service.create_role('key', 'name', 'desc', 1) + assert result == mock_doc + self.mock_handler.create_role.assert_awaited_once_with('key', 'name', 'desc', 1) + + async def test_create_role_fail(self): + # Test that an exception is raised if the handler fails to create a role + self.mock_handler.create_role = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.create_role('key', 'name', 'desc', 1) + + async def test_create_role_with_none_description(self): + # Test creating a role with None as description + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.create_role = AsyncMock(return_value=mock_doc) + result = await self.service.create_role('key', 'name', None, 1) + assert result == mock_doc + self.mock_handler.create_role.assert_awaited_once_with('key', 'name', None, 1) + + async def test_create_role_with_empty_description(self): + # Test creating a role with empty string as description + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.create_role = AsyncMock(return_value=mock_doc) + result = await self.service.create_role('key', 'name', '', 1) + assert result == mock_doc + self.mock_handler.create_role.assert_awaited_once_with('key', 'name', '', 1) + + async def test_create_role_handler_unexpected_exception(self): + # Test handler raises unexpected exception during creation + self.mock_handler.create_role = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.create_role('key', 'name', 'desc', 1) + + async def test_update_role_success(self): + # Test updating a role successfully returns the expected RoleDoc + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.update_role = AsyncMock(return_value=mock_doc) + result = await self.service.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + assert result == mock_doc + self.mock_handler.update_role.assert_awaited_once() + + async def test_update_role_fail(self): + # Test that an exception is raised if the handler fails to update a role + self.mock_handler.update_role = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + + async def test_update_role_handler_unexpected_exception(self): + # Test handler raises unexpected exception during update + self.mock_handler.update_role = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + + async def test_update_role_partial_args(self): + # Test updating a role with only key provided + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.update_role = AsyncMock(return_value=mock_doc) + result = await self.service.update_role('507f1f77bcf86cd799439011', 'key', None, None, 1) + assert result == mock_doc + self.mock_handler.update_role.assert_awaited_once() + + async def test_query_roles_success(self): + # Test querying roles returns a paginated result with correct items and meta + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.dict.return_value = {'role_key': 'key', 'role_name': 'name'} + self.mock_handler.query_roles = AsyncMock(return_value=([mock_doc], 1)) + result = await self.service.query_roles('key', 'name', 1, 10) + assert result['items'] == [{'role_key': 'key', 'role_name': 'name'}] + assert result['total'] == 1 + assert result['page'] == 1 + assert result['page_size'] == 10 + self.mock_handler.query_roles.assert_awaited_once_with('key', 'name', 0, 10) + + async def test_query_roles_handler_exception(self): + # Test handler raises exception during query + self.mock_handler.query_roles = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.query_roles('key', 'name', 1, 10) + + async def test_query_roles_empty_result(self): + # Test query returns empty list + self.mock_handler.query_roles = AsyncMock(return_value=([], 0)) + result = await self.service.query_roles('key', 'name', 1, 10) + assert result['items'] == [] + assert result['total'] == 0 + assert result['page'] == 1 + assert result['page_size'] == 10 + + async def test_query_roles_with_none_and_special_chars(self): + # Test query with None and special characters + self.mock_handler.query_roles = AsyncMock(return_value=([], 0)) + result = await self.service.query_roles(None, None, 1, 10) + assert result['items'] == [] + result2 = await self.service.query_roles('!@#$', '', 1, 10) + assert result2['items'] == [] + + @pytest.mark.parametrize('page,page_size', [(0, 10), (1, 0), (0, 0), (-1, 10), (1, -1)]) + async def test_query_roles_invalid_page(self, page, page_size): + # Test that invalid page or page_size raises a validation error + with pytest.raises(RequestValidationError): + await self.service.query_roles('key', 'name', page, page_size) + + async def test_assign_permissions_to_role_success(self): + # Test assigning permissions to a role returns the updated RoleDoc + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.assign_permissions_to_role = AsyncMock(return_value=mock_doc) + result = await self.service.assign_permissions_to_role('507f1f77bcf86cd799439011', ['pid1', 'pid2']) + assert result == mock_doc + self.mock_handler.assign_permissions_to_role.assert_awaited_once() + + async def test_assign_permissions_to_role_fail(self): + # Test that an exception is raised if the handler fails to assign permissions + self.mock_handler.assign_permissions_to_role = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.assign_permissions_to_role('507f1f77bcf86cd799439011', ['pid1', 'pid2']) + + async def test_assign_permissions_to_role_empty_list(self): + # Test assigning permissions to a role with empty permission_ids list + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.assign_permissions_to_role = AsyncMock(return_value=mock_doc) + result = await self.service.assign_permissions_to_role('507f1f77bcf86cd799439011', []) + assert result == mock_doc + self.mock_handler.assign_permissions_to_role.assert_awaited_once() + + async def test_assign_permissions_to_role_handler_unexpected_exception(self): + # Test handler raises unexpected exception during assign + self.mock_handler.assign_permissions_to_role = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.assign_permissions_to_role('507f1f77bcf86cd799439011', ['pid1']) + + async def test_delete_role_success(self): + # Test deleting a role successfully returns None + self.mock_handler.delete_role = AsyncMock(return_value=None) + result = await self.service.delete_role('507f1f77bcf86cd799439011') + assert result is None + self.mock_handler.delete_role.assert_awaited_once() + + async def test_delete_role_fail(self): + # Test that an exception is raised if the handler fails to delete a role + self.mock_handler.delete_role = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.delete_role('507f1f77bcf86cd799439011') + + async def test_delete_role_handler_unexpected_exception(self): + # Test handler raises unexpected exception during delete + self.mock_handler.delete_role = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.delete_role('507f1f77bcf86cd799439011') \ No newline at end of file diff --git a/tests/unit_tests/backend/permission/user_role_handler/__init__.py b/tests/unit_tests/backend/permission/user_role_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/user/__init__.py b/tests/unit_tests/backend/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/user/user_management_service/__init__.py b/tests/unit_tests/backend/user/user_management_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/backend/user/user_management_service/log/authentication-application-activity.log b/tests/unit_tests/backend/user/user_management_service/log/authentication-application-activity.log new file mode 100644 index 0000000..4f5bb17 --- /dev/null +++ b/tests/unit_tests/backend/user/user_management_service/log/authentication-application-activity.log @@ -0,0 +1,54 @@ +{"level": "info", "timestamp": 1753417629389, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753417629389, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 273000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753417629391, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753417629391, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 123000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "127.0.0.1", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422337463, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422337463, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 225000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422337465, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422337465, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 119000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422337467, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422337467, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 101000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 66, in create_new_user_account\n return user_account\n", "UnboundLocalError: local variable 'user_account' referenced before assignment\n"]} +{"level": "info", "timestamp": 1753422337504, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422337504, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 191000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 48, in create_new_user_account\n user_account = await self.user_profile_handler.create_new_user_account(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/unittest/mock.py\", line 2234, in _execute_mock_call\n raise effect\n", "Exception: db error\n"]} +{"level": "info", "timestamp": 1753422377608, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422377608, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 225000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 48, in create_new_user_account\n user_account = await self.user_profile_handler.create_new_user_account(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/unittest/mock.py\", line 2234, in _execute_mock_call\n raise effect\n", "Exception: db error\n"]} +{"level": "info", "timestamp": 1753422387561, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422387561, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 202000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 48, in create_new_user_account\n user_account = await self.user_profile_handler.create_new_user_account(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/unittest/mock.py\", line 2234, in _execute_mock_call\n raise effect\n", "Exception: db error\n"]} +{"level": "info", "timestamp": 1753422392157, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422392157, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 202000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422392160, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422392160, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 159000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422392161, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422392161, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 94000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 66, in create_new_user_account\n return user_account\n", "UnboundLocalError: local variable 'user_account' referenced before assignment\n"]} +{"level": "info", "timestamp": 1753422392196, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422392196, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 178000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 48, in create_new_user_account\n user_account = await self.user_profile_handler.create_new_user_account(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/unittest/mock.py\", line 2234, in _execute_mock_call\n raise effect\n", "Exception: db error\n"]} +{"level": "info", "timestamp": 1753422423722, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422423722, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 231000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422423724, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422423725, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 131000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422423726, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422423726, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 88000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 66, in create_new_user_account\n return user_account\n", "UnboundLocalError: local variable 'user_account' referenced before assignment\n"]} +{"level": "info", "timestamp": 1753422423765, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422423765, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 170000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 48, in create_new_user_account\n user_account = await self.user_profile_handler.create_new_user_account(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/unittest/mock.py\", line 2234, in _execute_mock_call\n raise effect\n", "Exception: db error\n"]} +{"level": "info", "timestamp": 1753422451865, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422451865, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 231000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422451868, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422451868, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 156000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422451870, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422451870, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 102000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 66, in create_new_user_account\n return user_account\n", "UnboundLocalError: local variable 'user_account' referenced before assignment\n"]} +{"level": "info", "timestamp": 1753422469485, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422469486, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 280000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422469488, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422469488, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 160000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422469491, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422469491, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 173000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 48, in create_new_user_account\n user_account = await self.user_profile_handler.create_new_user_account(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/unittest/mock.py\", line 2234, in _execute_mock_call\n raise effect\n", "Exception: db error\n"]} +{"level": "info", "timestamp": 1753422511406, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422511407, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 325000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 66, in create_new_user_account\n return user_account\n", "UnboundLocalError: local variable 'user_account' referenced before assignment\n"]} +{"level": "info", "timestamp": 1753422520925, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422520926, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 1130000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 66, in create_new_user_account\n return user_account\n", "UnboundLocalError: local variable 'user_account' referenced before assignment\n"]} +{"level": "info", "timestamp": 1753422541458, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422541459, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 273000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422541461, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422541461, "text": "Exit:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 137000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 25, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exit", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "info", "timestamp": 1753422541463, "text": "Enter:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py"}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 12, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "enter", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": null} +{"level": "error", "timestamp": 1753422541463, "text": "Exception:create_new_user_account of ../../../../../backend/services/user/user_management_service.py", "fields": {"function": "create_new_user_account", "file": "../../../../../backend/services/user/user_management_service.py", "excution_time_in_ns": 127000}, "context": {"app": "authentication", "env": "alpha", "log_file": "function_logger.py", "log_line": 39, "topic": "authentication-application-activity", "sender_id": "log_entry_exit_async", "receiver_id": "function_logger", "subject": "function", "event": "exception", "host_ip": "192.168.0.109", "host_name": "chengdeMacBook-Air.local"}, "stacktrace": ["Traceback (most recent call last):\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/common/log/log_utils.py\", line 15, in wrapper\n result = await func(*args, **kwargs)\n", " File \"/Users/cheng/Code/Freeleaps/freeleaps-service-hub/apps/authentication/backend/services/user/user_management_service.py\", line 48, in create_new_user_account\n user_account = await self.user_profile_handler.create_new_user_account(\n", " File \"/opt/anaconda3/envs/freeleaps-3.10/lib/python3.10/unittest/mock.py\", line 2234, in _execute_mock_call\n raise effect\n", "Exception: db error\n"]} diff --git a/tests/unit_tests/backend/user/user_management_service/test_user_management_service.py b/tests/unit_tests/backend/user/user_management_service/test_user_management_service.py new file mode 100644 index 0000000..8787839 --- /dev/null +++ b/tests/unit_tests/backend/user/user_management_service/test_user_management_service.py @@ -0,0 +1,172 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from backend.services.user.user_management_service import UserManagementService +from backend.models.user.models import UserAccountDoc +from backend.models.permission.models import UserRoleDoc +from backend.models.user.constants import NewUserMethod +from common.constants.region import UserRegion + +@pytest.fixture +def mock_handlers(): + with patch('backend.services.user.user_management_service.UserProfileHandler') as MockProfileHandler, \ + patch('backend.services.user.user_management_service.UserAuthHandler') as MockAuthHandler, \ + patch('backend.services.user.user_management_service.UserRoleHandler') as MockRoleHandler: + yield MockProfileHandler, MockAuthHandler, MockRoleHandler + +@pytest.mark.asyncio +class TestUserManagementService: + @pytest.fixture(autouse=True) + def setup(self, mock_handlers): + # Automatically set up a UserManagementService with mocked handlers for each test + MockProfileHandler, MockAuthHandler, MockRoleHandler = mock_handlers + self.mock_profile_handler = MockProfileHandler.return_value + self.mock_auth_handler = MockAuthHandler.return_value + self.mock_role_handler = MockRoleHandler.return_value + self.service = UserManagementService() + self.service.user_profile_handler = self.mock_profile_handler + self.service.user_auth_handler = self.mock_auth_handler + self.service.user_role_handler = self.mock_role_handler + + async def test_create_new_user_account_email(self): + # Test creating a new user account with EMAIL method + mock_account = MagicMock(spec=UserAccountDoc) + self.mock_profile_handler.create_new_user_account = AsyncMock(return_value=mock_account) + result = await self.service.create_new_user_account(NewUserMethod.EMAIL, UserRegion.ZH_CN) + assert result == mock_account + self.mock_profile_handler.create_new_user_account.assert_awaited_once() + + async def test_create_new_user_account_mobile(self): + # Test creating a new user account with MOBILE method + mock_account = MagicMock(spec=UserAccountDoc) + self.mock_profile_handler.create_new_user_account = AsyncMock(return_value=mock_account) + result = await self.service.create_new_user_account(NewUserMethod.MOBILE, UserRegion.ZH_CN) + assert result == mock_account + self.mock_profile_handler.create_new_user_account.assert_awaited_once() + + + async def test_create_new_user_account_handler_exception(self): + # Test handler exception is propagated when creating a new user account + self.mock_profile_handler.create_new_user_account = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.create_new_user_account(NewUserMethod.EMAIL, UserRegion.ZH_CN) + + async def test_initialize_new_user_data_email(self): + # Test initializing new user data with EMAIL method + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_auth_handler.save_email_auth_method = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock() + result = await self.service.initialize_new_user_data('uid', NewUserMethod.EMAIL, email_address='a@b.com') + assert result is True + self.mock_profile_handler.create_basic_profile.assert_awaited_once() + self.mock_auth_handler.save_email_auth_method.assert_awaited_once() + self.mock_profile_handler.create_provider_profile.assert_awaited_once() + + async def test_initialize_new_user_data_mobile(self): + # Test initializing new user data with MOBILE method + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock() + result = await self.service.initialize_new_user_data('uid', NewUserMethod.MOBILE, mobile_number='123456') + assert result is True + self.mock_profile_handler.create_basic_profile.assert_awaited_once() + self.mock_profile_handler.create_provider_profile.assert_awaited_once() + + async def test_initialize_new_user_data_other(self): + # Test initializing new user data with unsupported method returns False + result = await self.service.initialize_new_user_data('uid', 'OTHER') + assert result is False + + async def test_initialize_new_user_data_email_handler_exception(self): + # Test exception in create_basic_profile is propagated + self.mock_profile_handler.create_basic_profile = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.initialize_new_user_data('uid', NewUserMethod.EMAIL, email_address='a@b.com') + + async def test_initialize_new_user_data_mobile_handler_exception(self): + # Test exception in create_basic_profile for MOBILE is propagated + self.mock_profile_handler.create_basic_profile = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.initialize_new_user_data('uid', NewUserMethod.MOBILE, mobile_number='123456') + + async def test_initialize_new_user_data_email_missing_email(self): + # Test initializing new user data with EMAIL method but missing email_address + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_auth_handler.save_email_auth_method = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock() + # Should still call with None, but may not be valid in real logic + result = await self.service.initialize_new_user_data('uid', NewUserMethod.EMAIL) + assert result is True + self.mock_profile_handler.create_basic_profile.assert_awaited_once() + self.mock_auth_handler.save_email_auth_method.assert_awaited_once() + self.mock_profile_handler.create_provider_profile.assert_awaited_once() + + async def test_initialize_new_user_data_mobile_missing_mobile(self): + # Test initializing new user data with MOBILE method but missing mobile_number + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock() + result = await self.service.initialize_new_user_data('uid', NewUserMethod.MOBILE) + assert result is True + self.mock_profile_handler.create_basic_profile.assert_awaited_once() + self.mock_profile_handler.create_provider_profile.assert_awaited_once() + + async def test_initialize_new_user_data_provider_profile_exception(self): + # Test exception in create_provider_profile is propagated + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_auth_handler.save_email_auth_method = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.initialize_new_user_data('uid', NewUserMethod.EMAIL, email_address='a@b.com') + + async def test_get_account_by_id(self): + # Test getting account by user id + mock_account = MagicMock(spec=UserAccountDoc) + self.mock_profile_handler.get_account_by_id = AsyncMock(return_value=mock_account) + result = await self.service.get_account_by_id('uid') + assert result == mock_account + self.mock_profile_handler.get_account_by_id.assert_awaited_once_with('uid') + + async def test_get_account_by_id_none(self): + # Test getting account by user id returns None if not found + self.mock_profile_handler.get_account_by_id = AsyncMock(return_value=None) + result = await self.service.get_account_by_id('uid') + assert result is None + self.mock_profile_handler.get_account_by_id.assert_awaited_once_with('uid') + + async def test_get_account_by_id_exception(self): + # Test exception in get_account_by_id is propagated + self.mock_profile_handler.get_account_by_id = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.get_account_by_id('uid') + + async def test_assign_roles_to_user(self): + # Test assigning roles to user + mock_user_role = MagicMock(spec=UserRoleDoc) + self.mock_role_handler.assign_roles_to_user = AsyncMock(return_value=mock_user_role) + result = await self.service.assign_roles_to_user('uid', ['rid1', 'rid2']) + assert result == mock_user_role + self.mock_role_handler.assign_roles_to_user.assert_awaited_once_with('uid', ['rid1', 'rid2']) + + async def test_assign_roles_to_user_handler_exception(self): + # Test exception in assign_roles_to_user is propagated + self.mock_role_handler.assign_roles_to_user = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.assign_roles_to_user('uid', ['rid1', 'rid2']) + + async def test_get_role_and_permission_by_user_id(self): + # Test getting role and permission by user id + self.mock_role_handler.get_role_and_permission_by_user_id = AsyncMock(return_value=(['role1'], ['perm1'])) + result = await self.service.get_role_and_permission_by_user_id('uid') + assert result == (['role1'], ['perm1']) + self.mock_role_handler.get_role_and_permission_by_user_id.assert_awaited_once_with('uid') + + async def test_get_role_and_permission_by_user_id_empty(self): + # Test getting role and permission by user id returns empty lists if no roles/permissions + self.mock_role_handler.get_role_and_permission_by_user_id = AsyncMock(return_value=([], [])) + result = await self.service.get_role_and_permission_by_user_id('uid') + assert result == ([], []) + self.mock_role_handler.get_role_and_permission_by_user_id.assert_awaited_once_with('uid') + + async def test_get_role_and_permission_by_user_id_exception(self): + # Test exception in get_role_and_permission_by_user_id is propagated + self.mock_role_handler.get_role_and_permission_by_user_id = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.get_role_and_permission_by_user_id('uid') \ No newline at end of file diff --git a/tests/util/__init__.py b/tests/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/util/temporary_email.py b/tests/util/temporary_email.py new file mode 100644 index 0000000..79d8e47 --- /dev/null +++ b/tests/util/temporary_email.py @@ -0,0 +1,76 @@ +import re +from time import sleep + +import requests +from faker import Faker + +TEMPORARY_EMAIL_DOMAIN = "https://api.mail.cx/api/v1" + + +def generate_email() -> str: + fake = Faker('en_US') + while True: + name = fake.name().replace(' ', '_') + if len(name) <= 10: + break + return f"{name}@nqmo.com" + + +def get_auth_email_token() -> str: + url = TEMPORARY_EMAIL_DOMAIN + "/auth/authorize_token" + headers = { + 'accept': 'application/json', + 'Authorization': 'Bearer undefined', + } + response = requests.post(url, headers=headers) + return str(response.json()) + + +def get_mail_id(address, token): + url = TEMPORARY_EMAIL_DOMAIN + f"/mailbox/{address}" + headers = { + 'accept': 'application/json', + 'Authorization': f'Bearer {token}', + } + response = requests.get(url, headers=headers) + body = response.json() + return body[0]['id'] if len(body) and len(body[0]['id']) > 0 else None + + +def get_auth_code(email): + # get token + token = get_auth_email_token() + print(f"token: {token}") + + # Waiting for verification code email + id_ = None + for _ in range(30): + id_ = get_mail_id(email, token) + if id_ is not None: + break + sleep(1) + if id_ is None: + raise Exception(f"Could not get auth code for {email}") + # get code + url = TEMPORARY_EMAIL_DOMAIN + f'/mailbox/{email}/{id_}' + headers = { + 'accept': 'application/json', + 'Authorization': f'Bearer {token}', + } + + response = requests.get(url, headers=headers) + print(response.json()) + + # Regular matching captcha, here the regular expression matching captcha is changed to its own + captcha = re.search(r'The auth code is:\s+(\d+)', response.json()['body']['html']) + if captcha: + print("code:", captcha.group(1)) + else: + print("Unable to find verification code") + return captcha.group(1) + + +if __name__ == '__main__': + email = generate_email() + code = get_auth_code(email) + print(code) diff --git a/web/.editorconfig b/web/.editorconfig deleted file mode 100644 index 5a5809d..0000000 --- a/web/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] -charset = utf-8 -indent_size = 2 -indent_style = space -insert_final_newline = true -trim_trailing_whitespace = true - -end_of_line = lf -max_line_length = 100 diff --git a/web/.gitattributes b/web/.gitattributes deleted file mode 100644 index 6313b56..0000000 --- a/web/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto eol=lf diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index aef72d0..0000000 --- a/web/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -.DS_Store -dist -dist-ssr -coverage -*.local - -/cypress/videos/ -/cypress/screenshots/ - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -*.tsbuildinfo - -test-results/ -playwright-report/ diff --git a/web/.prettierrc.json b/web/.prettierrc.json deleted file mode 100644 index 29a2402..0000000 --- a/web/.prettierrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/prettierrc", - "semi": false, - "singleQuote": true, - "printWidth": 100 -} diff --git a/web/docker-entrypoint.sh b/web/docker-entrypoint.sh deleted file mode 100644 index 41ba44d..0000000 --- a/web/docker-entrypoint.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh -set -e - -# Default value for API_SERVER_URL if not provided -API_SERVER_URL=${API_SERVER_URL:-http://api-server:8888/} - -# Check if NAMESPACE is set and valid -if [ -n "$NAMESPACE" ]; then - # Kubernetes namespace validation - if echo "$NAMESPACE" | grep -Eq '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$' && [ ${#NAMESPACE} -le 63 ]; then - # Check if API_SERVER_URL is standard FQDN - if echo "$API_SERVER_URL" | grep -q '\.svc\.freeleaps\.cluster'; then - # CHeck if is FQDN but not contains namespace - if ! echo "$API_SERVER_URL" | grep -q "\\.$NAMESPACE\\.svc\\.freeleaps\\.cluster"; then - API_SERVER_URL=$(echo "$API_SERVER_URL" | sed -E "s/([a-zA-Z0-9-]+)\.svc\.freeleaps\.cluster/\1.$NAMESPACE.svc.freeleaps.cluster/") - fi - else - # If not FQDN, convert to FQDN - host_part=$(echo "$API_SERVER_URL" | sed -nE 's!^(https?://)([^:/]+)(.*)$!\2!p') - if [ -n "$host_part" ]; then - # Replace host name part to FQDN - API_SERVER_URL=$(echo "$API_SERVER_URL" | sed -E "s!$host_part!$host_part.$NAMESPACE.svc.freeleaps.cluster!") - fi - fi - else - echo "ERROR: Invalid Namespace '$NAMESPACE', skip FQDN convertion" - fi -fi - -# Replace the environment variable in the nginx config -envsubst '${API_SERVER_URL}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf - -# Start nginx -exec nginx -g 'daemon off;' \ No newline at end of file diff --git a/web/e2e/tsconfig.json b/web/e2e/tsconfig.json deleted file mode 100644 index f31fe71..0000000 --- a/web/e2e/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "@tsconfig/node22/tsconfig.json", - "include": ["./**/*"] -} diff --git a/web/e2e/vue.spec.ts b/web/e2e/vue.spec.ts deleted file mode 100644 index fc116a9..0000000 --- a/web/e2e/vue.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { test, expect } from '@playwright/test'; - -// See here how to get started: -// https://playwright.dev/docs/intro -test('visits the app root url', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('h1')).toHaveText('You did it!'); -}) diff --git a/web/env.d.ts b/web/env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/web/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/web/eslint.config.ts b/web/eslint.config.ts deleted file mode 100644 index 0a90260..0000000 --- a/web/eslint.config.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { globalIgnores } from 'eslint/config' -import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' -import pluginVue from 'eslint-plugin-vue' -import pluginVitest from '@vitest/eslint-plugin' -import pluginPlaywright from 'eslint-plugin-playwright' -import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' - -// To allow more languages other than `ts` in `.vue` files, uncomment the following lines: -// import { configureVueProject } from '@vue/eslint-config-typescript' -// configureVueProject({ scriptLangs: ['ts', 'tsx'] }) -// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup - -export default defineConfigWithVueTs( - { - name: 'app/files-to-lint', - files: ['**/*.{ts,mts,tsx,vue}'], - }, - - globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), - - pluginVue.configs['flat/essential'], - vueTsConfigs.recommended, - - { - ...pluginVitest.configs.recommended, - files: ['src/**/__tests__/*'], - }, - - { - ...pluginPlaywright.configs['flat/recommended'], - files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'], - }, - skipFormatting, -) diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 9e5fc8f..0000000 --- a/web/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite App - - -
- - - diff --git a/web/nginx/default.conf b/web/nginx/default.conf deleted file mode 100644 index 13945e7..0000000 --- a/web/nginx/default.conf +++ /dev/null @@ -1,44 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Gzip compression - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - gzip_min_length 1000; - gzip_proxied any; - - # Cache control for static assets - location /assets/ { - expires 1y; - add_header Cache-Control "public, no-transform"; - access_log off; - } - - # Handle Vue router history mode - location / { - try_files $uri $uri/ /index.html; - expires -1; - add_header Cache-Control "no-store, no-cache, must-revalidate"; - } - - # Security headers - add_header X-Frame-Options "SAMEORIGIN"; - add_header X-XSS-Protection "1; mode=block"; - add_header X-Content-Type-Options "nosniff"; - - # Proxy API requests to the backend - location /api/ { - proxy_pass ${API_SERVER_URL}; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index de6b70d..0000000 --- a/web/package-lock.json +++ /dev/null @@ -1,6676 +0,0 @@ -{ - "name": "vue-project", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "vue-project", - "version": "0.0.0", - "dependencies": { - "pinia": "^3.0.1", - "vue": "^3.5.13", - "vue-router": "^4.5.0" - }, - "devDependencies": { - "@playwright/test": "^1.51.1", - "@tsconfig/node22": "^22.0.1", - "@types/jsdom": "^21.1.7", - "@types/node": "^22.14.0", - "@vitejs/plugin-vue": "^5.2.3", - "@vitejs/plugin-vue-jsx": "^4.1.2", - "@vitest/eslint-plugin": "^1.1.39", - "@vue/eslint-config-prettier": "^10.2.0", - "@vue/eslint-config-typescript": "^14.5.0", - "@vue/test-utils": "^2.4.6", - "@vue/tsconfig": "^0.7.0", - "eslint": "^9.22.0", - "eslint-plugin-playwright": "^2.2.0", - "eslint-plugin-vue": "~10.0.0", - "jiti": "^2.4.2", - "jsdom": "^26.0.0", - "npm-run-all2": "^7.0.2", - "prettier": "3.5.3", - "typescript": "~5.8.0", - "vite": "^6.2.4", - "vite-plugin-vue-devtools": "^7.7.2", - "vitest": "^3.1.1", - "vue-tsc": "^2.2.8" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@antfu/utils": { - "version": "0.7.10", - "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz", - "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", - "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", - "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helpers": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", - "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", - "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", - "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", - "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", - "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.27.1.tgz", - "integrity": "sha512-DTxe4LBPrtFdsWzgpmbBKevg3e9PBy+dXRt19kSbucbZvL2uqtdqwwpluL1jfxYE0wIDTFp1nTy/q6gNLsxXrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-decorators": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", - "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz", - "integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", - "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", - "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.3.tgz", - "integrity": "sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.9.tgz", - "integrity": "sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.0.2", - "@csstools/css-calc": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", - "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.3" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", - "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", - "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", - "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", - "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", - "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", - "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", - "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", - "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", - "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", - "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", - "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", - "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", - "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", - "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", - "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", - "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", - "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", - "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", - "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", - "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", - "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", - "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", - "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", - "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", - "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", - "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", - "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.14.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@one-ini/wasm": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.4.tgz", - "integrity": "sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@playwright/test": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", - "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.52.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", - "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz", - "integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz", - "integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz", - "integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz", - "integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz", - "integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz", - "integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz", - "integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz", - "integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz", - "integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz", - "integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz", - "integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz", - "integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz", - "integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz", - "integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz", - "integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz", - "integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz", - "integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz", - "integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz", - "integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz", - "integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@tsconfig/node22": { - "version": "22.0.2", - "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.2.tgz", - "integrity": "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsdom": { - "version": "21.1.7", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", - "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", - "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/type-utils": "8.32.1", - "@typescript-eslint/utils": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", - "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", - "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", - "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", - "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/utils": "8.32.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", - "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", - "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", - "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", - "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.32.1", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vitejs/plugin-vue-jsx": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-4.2.0.tgz", - "integrity": "sha512-DSTrmrdLp+0LDNF77fqrKfx7X0ErRbOcUAgJL/HbSesqQwoUvUQ4uYQqaex+rovqgGcoPqVk+AwUh3v9CuiYIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.27.1", - "@rolldown/pluginutils": "^1.0.0-beta.9", - "@vue/babel-plugin-jsx": "^1.4.0" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.0.0" - } - }, - "node_modules/@vitest/eslint-plugin": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.2.0.tgz", - "integrity": "sha512-6vn3QDy+ysqHGkbH9fU9uyWptqNc638dgPy0uAlh/XpniTBp+0WeVlXGW74zqggex/CwYOhK8t5GVo/FH3NMPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.24.0" - }, - "peerDependencies": { - "eslint": ">= 8.57.0", - "typescript": ">= 5.0.0", - "vitest": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz", - "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.1.4", - "@vitest/utils": "3.1.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz", - "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.1.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz", - "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz", - "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.1.4", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz", - "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.1.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz", - "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz", - "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.1.4", - "loupe": "^3.1.3", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.14", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.14.tgz", - "integrity": "sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.14" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.14", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.14.tgz", - "integrity": "sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.14", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.14.tgz", - "integrity": "sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.14", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vue/babel-helper-vue-transform-on": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.4.0.tgz", - "integrity": "sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vue/babel-plugin-jsx": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.4.0.tgz", - "integrity": "sha512-9zAHmwgMWlaN6qRKdrg1uKsBKHvnUU+Py+MOCTuYZBoZsopa90Di10QRjB+YPnVss0BZbG/H5XFwJY1fTxJWhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/plugin-syntax-jsx": "^7.25.9", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", - "@vue/babel-helper-vue-transform-on": "1.4.0", - "@vue/babel-plugin-resolve-type": "1.4.0", - "@vue/shared": "^3.5.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - } - } - }, - "node_modules/@vue/babel-plugin-resolve-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.4.0.tgz", - "integrity": "sha512-4xqDRRbQQEWHQyjlYSgZsWj44KfiF6D+ktCuXyZ8EnVDYV3pztmXJDf1HveAjUAXxAnR8daCQT51RneWWxtTyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/parser": "^7.26.9", - "@vue/compiler-sfc": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.14.tgz", - "integrity": "sha512-k7qMHMbKvoCXIxPhquKQVw3Twid3Kg4s7+oYURxLGRd56LiuHJVrvFKI4fm2AM3c8apqODPfVJGoh8nePbXMRA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.2", - "@vue/shared": "3.5.14", - "entities": "^4.5.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.14.tgz", - "integrity": "sha512-1aOCSqxGOea5I80U2hQJvXYpPm/aXo95xL/m/mMhgyPUsKe9jhjwWpziNAw7tYRnbz1I61rd9Mld4W9KmmRoug==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.14", - "@vue/shared": "3.5.14" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.14.tgz", - "integrity": "sha512-9T6m/9mMr81Lj58JpzsiSIjBgv2LiVoWjIVa7kuXHICUi8LiDSIotMpPRXYJsXKqyARrzjT24NAwttrMnMaCXA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.2", - "@vue/compiler-core": "3.5.14", - "@vue/compiler-dom": "3.5.14", - "@vue/compiler-ssr": "3.5.14", - "@vue/shared": "3.5.14", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.17", - "postcss": "^8.5.3", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.14.tgz", - "integrity": "sha512-Y0G7PcBxr1yllnHuS/NxNCSPWnRGH4Ogrp0tsLA5QemDZuJLs99YjAKQ7KqkHE0vCg4QTKlQzXLKCMF7WPSl7Q==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.14", - "@vue/shared": "3.5.14" - } - }, - "node_modules/@vue/compiler-vue2": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", - "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, - "node_modules/@vue/devtools-api": { - "version": "7.7.6", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.6.tgz", - "integrity": "sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw==", - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^7.7.6" - } - }, - "node_modules/@vue/devtools-core": { - "version": "7.7.6", - "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-7.7.6.tgz", - "integrity": "sha512-ghVX3zjKPtSHu94Xs03giRIeIWlb9M+gvDRVpIZ/cRIxKHdW6HE/sm1PT3rUYS3aV92CazirT93ne+7IOvGUWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^7.7.6", - "@vue/devtools-shared": "^7.7.6", - "mitt": "^3.0.1", - "nanoid": "^5.1.0", - "pathe": "^2.0.3", - "vite-hot-client": "^2.0.4" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/@vue/devtools-core/node_modules/nanoid": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", - "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.6", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.6.tgz", - "integrity": "sha512-geu7ds7tem2Y7Wz+WgbnbZ6T5eadOvozHZ23Atk/8tksHMFOFylKi1xgGlQlVn0wlkEf4hu+vd5ctj1G4kFtwA==", - "license": "MIT", - "dependencies": { - "@vue/devtools-shared": "^7.7.6", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.6", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.6.tgz", - "integrity": "sha512-yFEgJZ/WblEsojQQceuyK6FzpFDx4kqrz2ohInxNj5/DnhoX023upTv4OD6lNPLAA5LLkbwPVb10o/7b+Y4FVA==", - "license": "MIT", - "dependencies": { - "rfdc": "^1.4.1" - } - }, - "node_modules/@vue/eslint-config-prettier": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", - "integrity": "sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2" - }, - "peerDependencies": { - "eslint": ">= 8.21.0", - "prettier": ">= 3.0.0" - } - }, - "node_modules/@vue/eslint-config-typescript": { - "version": "14.5.0", - "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.5.0.tgz", - "integrity": "sha512-5oPOyuwkw++AP5gHDh5YFmST50dPfWOcm3/W7Nbh42IK5O3H74ytWAw0TrCRTaBoD/02khnWXuZf1Bz1xflavQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.26.0", - "fast-glob": "^3.3.3", - "typescript-eslint": "^8.26.0", - "vue-eslint-parser": "^10.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "peerDependencies": { - "eslint": "^9.10.0", - "eslint-plugin-vue": "^9.28.0 || ^10.0.0", - "typescript": ">=4.8.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@vue/language-core": { - "version": "2.2.10", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.10.tgz", - "integrity": "sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "~2.4.11", - "@vue/compiler-dom": "^3.5.0", - "@vue/compiler-vue2": "^2.7.16", - "@vue/shared": "^3.5.0", - "alien-signals": "^1.0.3", - "minimatch": "^9.0.3", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.14.tgz", - "integrity": "sha512-7cK1Hp343Fu/SUCCO52vCabjvsYu7ZkOqyYu7bXV9P2yyfjUMUXHZafEbq244sP7gf+EZEz+77QixBTuEqkQQw==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.14" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.14.tgz", - "integrity": "sha512-w9JWEANwHXNgieAhxPpEpJa+0V5G0hz3NmjAZwlOebtfKyp2hKxKF0+qSh0Xs6/PhfGihuSdqMprMVcQU/E6ag==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.14", - "@vue/shared": "3.5.14" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.14.tgz", - "integrity": "sha512-lCfR++IakeI35TVR80QgOelsUIdcKjd65rWAMfdSlCYnaEY5t3hYwru7vvcWaqmrK+LpI7ZDDYiGU5V3xjMacw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.14", - "@vue/runtime-core": "3.5.14", - "@vue/shared": "3.5.14", - "csstype": "^3.1.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.14.tgz", - "integrity": "sha512-Rf/ISLqokIvcySIYnv3tNWq40PLpNLDLSJwwVWzG6MNtyIhfbcrAxo5ZL9nARJhqjZyWWa40oRb2IDuejeuv6w==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.14", - "@vue/shared": "3.5.14" - }, - "peerDependencies": { - "vue": "3.5.14" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.14.tgz", - "integrity": "sha512-oXTwNxVfc9EtP1zzXAlSlgARLXNC84frFYkS0HHz0h3E4WZSP9sywqjqzGCP9Y34M8ipNmd380pVgmMuwELDyQ==", - "license": "MIT" - }, - "node_modules/@vue/test-utils": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", - "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-beautify": "^1.14.9", - "vue-component-type-helpers": "^2.0.0" - } - }, - "node_modules/@vue/tsconfig": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz", - "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "typescript": "5.x", - "vue": "^3.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, - "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/alien-signals": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", - "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/birpc": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz", - "integrity": "sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/copy-anything": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", - "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", - "license": "MIT", - "dependencies": { - "is-what": "^4.1.8" - }, - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssstyle": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.3.1.tgz", - "integrity": "sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.1.2", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", - "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", - "dev": true, - "license": "MIT" - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/editorconfig": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", - "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@one-ini/wasm": "0.1.1", - "commander": "^10.0.0", - "minimatch": "9.0.1", - "semver": "^7.5.3" - }, - "bin": { - "editorconfig": "bin/editorconfig" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/editorconfig/node_modules/minimatch": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", - "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/editorconfig/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/error-stack-parser-es": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-0.1.5.tgz", - "integrity": "sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", - "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.4", - "@esbuild/android-arm": "0.25.4", - "@esbuild/android-arm64": "0.25.4", - "@esbuild/android-x64": "0.25.4", - "@esbuild/darwin-arm64": "0.25.4", - "@esbuild/darwin-x64": "0.25.4", - "@esbuild/freebsd-arm64": "0.25.4", - "@esbuild/freebsd-x64": "0.25.4", - "@esbuild/linux-arm": "0.25.4", - "@esbuild/linux-arm64": "0.25.4", - "@esbuild/linux-ia32": "0.25.4", - "@esbuild/linux-loong64": "0.25.4", - "@esbuild/linux-mips64el": "0.25.4", - "@esbuild/linux-ppc64": "0.25.4", - "@esbuild/linux-riscv64": "0.25.4", - "@esbuild/linux-s390x": "0.25.4", - "@esbuild/linux-x64": "0.25.4", - "@esbuild/netbsd-arm64": "0.25.4", - "@esbuild/netbsd-x64": "0.25.4", - "@esbuild/openbsd-arm64": "0.25.4", - "@esbuild/openbsd-x64": "0.25.4", - "@esbuild/sunos-x64": "0.25.4", - "@esbuild/win32-arm64": "0.25.4", - "@esbuild/win32-ia32": "0.25.4", - "@esbuild/win32-x64": "0.25.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", - "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.27.0", - "@eslint/plugin-kit": "^0.3.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", - "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-playwright": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.2.0.tgz", - "integrity": "sha512-qSQpAw7RcSzE3zPp8FMGkthaCWovHZ/BsXtpmnGax9vQLIovlh1bsZHEa2+j2lv9DWhnyeLM/qZmp7ffQZfQvg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "examples" - ], - "dependencies": { - "globals": "^13.23.0" - }, - "engines": { - "node": ">=16.6.0" - }, - "peerDependencies": { - "eslint": ">=8.40.0" - } - }, - "node_modules/eslint-plugin-playwright/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.0.tgz", - "integrity": "sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-vue": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.0.1.tgz", - "integrity": "sha512-A5dRYc3eQ5i2rJFBW8J6F69ur/H7YfYg+5SCg6v829FU0BhM4fUTrRVR2d4MdZgzw0ioJEk6otYHEAnoGFqO4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "natural-compare": "^1.4.0", - "nth-check": "^2.1.1", - "postcss-selector-parser": "^6.0.15", - "semver": "^7.6.3", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "vue-eslint-parser": "^10.0.0" - } - }, - "node_modules/eslint-plugin-vue/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/execa": { - "version": "9.5.3", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.5.3.tgz", - "integrity": "sha512-QFNnTvU3UjgWFy8Ef9iDHvIdcgZ344ebkwYx4/KLbR+CKQA4xBaHzv+iRpp86QfMHP8faFQLh8iOc57215y4Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.3", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.0", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/expect-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", - "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "license": "MIT" - }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-what": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", - "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", - "license": "MIT", - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-beautify": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "config-chain": "^1.1.13", - "editorconfig": "^1.0.4", - "glob": "^10.4.2", - "js-cookie": "^3.0.5", - "nopt": "^7.2.1" - }, - "bin": { - "css-beautify": "js/bin/css-beautify.js", - "html-beautify": "js/bin/html-beautify.js", - "js-beautify": "js/bin/js-beautify.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", - "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kolorist": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", - "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/memorystream": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", - "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", - "dev": true, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-normalize-package-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", - "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-run-all2": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-7.0.2.tgz", - "integrity": "sha512-7tXR+r9hzRNOPNTvXegM+QzCuMjzUIIq66VDunL6j60O4RrExx32XUhlrS7UK4VcdGw5/Wxzb3kfNcFix9JKDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "cross-spawn": "^7.0.6", - "memorystream": "^0.3.1", - "minimatch": "^9.0.0", - "pidtree": "^0.6.0", - "read-package-json-fast": "^4.0.0", - "shell-quote": "^1.7.3", - "which": "^5.0.0" - }, - "bin": { - "npm-run-all": "bin/npm-run-all/index.js", - "npm-run-all2": "bin/npm-run-all/index.js", - "run-p": "bin/run-p/index.js", - "run-s": "bin/run-s/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0", - "npm": ">= 9" - } - }, - "node_modules/npm-run-all2/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm-run-all2/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/npm-run-all2/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/nwsapi": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", - "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", - "dev": true, - "license": "MIT" - }, - "node_modules/open": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", - "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/pinia": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.2.tgz", - "integrity": "sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^7.7.2" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.4.4", - "vue": "^2.7.0 || ^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/playwright": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", - "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.52.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", - "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/pretty-ms": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", - "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true, - "license": "ISC" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/read-package-json-fast": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", - "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz", - "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.7" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.41.0", - "@rollup/rollup-android-arm64": "4.41.0", - "@rollup/rollup-darwin-arm64": "4.41.0", - "@rollup/rollup-darwin-x64": "4.41.0", - "@rollup/rollup-freebsd-arm64": "4.41.0", - "@rollup/rollup-freebsd-x64": "4.41.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.41.0", - "@rollup/rollup-linux-arm-musleabihf": "4.41.0", - "@rollup/rollup-linux-arm64-gnu": "4.41.0", - "@rollup/rollup-linux-arm64-musl": "4.41.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.41.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0", - "@rollup/rollup-linux-riscv64-gnu": "4.41.0", - "@rollup/rollup-linux-riscv64-musl": "4.41.0", - "@rollup/rollup-linux-s390x-gnu": "4.41.0", - "@rollup/rollup-linux-x64-gnu": "4.41.0", - "@rollup/rollup-linux-x64-musl": "4.41.0", - "@rollup/rollup-win32-arm64-msvc": "4.41.0", - "@rollup/rollup-win32-ia32-msvc": "4.41.0", - "@rollup/rollup-win32-x64-msvc": "4.41.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sirv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", - "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/superjson": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", - "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", - "license": "MIT", - "dependencies": { - "copy-anything": "^3.0.2" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/synckit": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.6.tgz", - "integrity": "sha512-2pR2ubZSV64f/vqm9eLPz/KOvR9Dm+Co/5ChLgeHl0yEDRc6h5hXHoxEQH8Y5Ljycozd3p1k5TTSVdzYGkPvLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.4" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.1.tgz", - "integrity": "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.32.1", - "@typescript-eslint/parser": "8.32.1", - "@typescript-eslint/utils": "8.32.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-hot-client": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/vite-hot-client/-/vite-hot-client-2.0.4.tgz", - "integrity": "sha512-W9LOGAyGMrbGArYJN4LBCdOC5+Zwh7dHvOHC0KmGKkJhsOzaKbpo/jEjpPKVHIW0/jBWj8RZG0NUxfgA8BxgAg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0" - } - }, - "node_modules/vite-node": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz", - "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.0", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-plugin-inspect": { - "version": "0.8.9", - "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-0.8.9.tgz", - "integrity": "sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@antfu/utils": "^0.7.10", - "@rollup/pluginutils": "^5.1.3", - "debug": "^4.3.7", - "error-stack-parser-es": "^0.1.5", - "fs-extra": "^11.2.0", - "open": "^10.1.0", - "perfect-debounce": "^1.0.0", - "picocolors": "^1.1.1", - "sirv": "^3.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.1" - }, - "peerDependenciesMeta": { - "@nuxt/kit": { - "optional": true - } - } - }, - "node_modules/vite-plugin-vue-devtools": { - "version": "7.7.6", - "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.7.6.tgz", - "integrity": "sha512-L7nPVM5a7lgit/Z+36iwoqHOaP3wxqVi1UvaDJwGCfblS9Y6vNqf32ILlzJVH9c47aHu90BhDXeZc+rgzHRHcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-core": "^7.7.6", - "@vue/devtools-kit": "^7.7.6", - "@vue/devtools-shared": "^7.7.6", - "execa": "^9.5.2", - "sirv": "^3.0.1", - "vite-plugin-inspect": "0.8.9", - "vite-plugin-vue-inspector": "^5.3.1" - }, - "engines": { - "node": ">=v14.21.3" - }, - "peerDependencies": { - "vite": "^3.1.0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0" - } - }, - "node_modules/vite-plugin-vue-inspector": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.1.tgz", - "integrity": "sha512-cBk172kZKTdvGpJuzCCLg8lJ909wopwsu3Ve9FsL1XsnLBiRT9U3MePcqrgGHgCX2ZgkqZmAGR8taxw+TV6s7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.23.0", - "@babel/plugin-proposal-decorators": "^7.23.0", - "@babel/plugin-syntax-import-attributes": "^7.22.5", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-transform-typescript": "^7.22.15", - "@vue/babel-plugin-jsx": "^1.1.5", - "@vue/compiler-dom": "^3.3.4", - "kolorist": "^1.8.0", - "magic-string": "^0.30.4" - }, - "peerDependencies": { - "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz", - "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "3.1.4", - "@vitest/mocker": "3.1.4", - "@vitest/pretty-format": "^3.1.4", - "@vitest/runner": "3.1.4", - "@vitest/snapshot": "3.1.4", - "@vitest/spy": "3.1.4", - "@vitest/utils": "3.1.4", - "chai": "^5.2.0", - "debug": "^4.4.0", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.13", - "tinypool": "^1.0.2", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.4", - "@vitest/ui": "3.1.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vue": { - "version": "3.5.14", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.14.tgz", - "integrity": "sha512-LbOm50/vZFG6Mhy6KscQYXZMQ0LMCC/y40HDJPPvGFQ+i/lUH+PJHR6C3assgOQiXdl6tAfsXHbXYVBZZu65ew==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.14", - "@vue/compiler-sfc": "3.5.14", - "@vue/runtime-dom": "3.5.14", - "@vue/server-renderer": "3.5.14", - "@vue/shared": "3.5.14" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-component-type-helpers": { - "version": "2.2.10", - "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.10.tgz", - "integrity": "sha512-iDUO7uQK+Sab2tYuiP9D1oLujCWlhHELHMgV/cB13cuGbG4qwkLHvtfWb6FzvxrIOPDnU0oHsz2MlQjhYDeaHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/vue-eslint-parser": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.1.3.tgz", - "integrity": "sha512-dbCBnd2e02dYWsXoqX5yKUZlOt+ExIpq7hmHKPb5ZqKcjf++Eo0hMseFTZMLKThrUk61m+Uv6A2YSBve6ZvuDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", - "esquery": "^1.6.0", - "lodash": "^4.17.21", - "semver": "^7.6.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - } - }, - "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/vue-eslint-parser/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/vue-router": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", - "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.4" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "vue": "^3.2.0" - } - }, - "node_modules/vue-router/node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, - "node_modules/vue-tsc": { - "version": "2.2.10", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.10.tgz", - "integrity": "sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/typescript": "~2.4.11", - "@vue/language-core": "2.2.10" - }, - "bin": { - "vue-tsc": "bin/vue-tsc.js" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", - "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/web/package.json b/web/package.json deleted file mode 100644 index 6e04f4a..0000000 --- a/web/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "freeleaps-authentication", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "run-p type-check \"build-only {@}\" --", - "preview": "vite preview", - "test:unit": "vitest", - "test:e2e": "playwright test", - "build-only": "vite build", - "type-check": "vue-tsc --build", - "lint": "eslint . --fix", - "format": "prettier --write src/" - }, - "dependencies": { - "pinia": "^3.0.1", - "vue": "^3.5.13", - "vue-router": "^4.5.0" - }, - "devDependencies": { - "@playwright/test": "^1.51.1", - "@tsconfig/node22": "^22.0.1", - "@types/jsdom": "^21.1.7", - "@types/node": "^22.14.0", - "@vitejs/plugin-vue": "^5.2.3", - "@vitejs/plugin-vue-jsx": "^4.1.2", - "@vitest/eslint-plugin": "^1.1.39", - "@vue/eslint-config-prettier": "^10.2.0", - "@vue/eslint-config-typescript": "^14.5.0", - "@vue/test-utils": "^2.4.6", - "@vue/tsconfig": "^0.7.0", - "eslint": "^9.22.0", - "eslint-plugin-playwright": "^2.2.0", - "eslint-plugin-vue": "~10.0.0", - "jiti": "^2.4.2", - "jsdom": "^26.0.0", - "npm-run-all2": "^7.0.2", - "prettier": "3.5.3", - "typescript": "~5.8.0", - "vite": "^6.2.4", - "vite-plugin-vue-devtools": "^7.7.2", - "vitest": "^3.1.1", - "vue-tsc": "^2.2.8" - } -} diff --git a/web/playwright.config.ts b/web/playwright.config.ts deleted file mode 100644 index 5ece956..0000000 --- a/web/playwright.config.ts +++ /dev/null @@ -1,110 +0,0 @@ -import process from 'node:process' -import { defineConfig, devices } from '@playwright/test' - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './e2e', - /* Maximum time one test can run for. */ - timeout: 30 * 1000, - expect: { - /** - * Maximum time expect() should wait for the condition to be met. - * For example in `await expect(locator).toHaveText();` - */ - timeout: 5000, - }, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ - actionTimeout: 0, - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - - /* Only on CI systems run the tests headless */ - headless: !!process.env.CI, - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - }, - }, - { - name: 'firefox', - use: { - ...devices['Desktop Firefox'], - }, - }, - { - name: 'webkit', - use: { - ...devices['Desktop Safari'], - }, - }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { - // ...devices['Pixel 5'], - // }, - // }, - // { - // name: 'Mobile Safari', - // use: { - // ...devices['iPhone 12'], - // }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { - // channel: 'msedge', - // }, - // }, - // { - // name: 'Google Chrome', - // use: { - // channel: 'chrome', - // }, - // }, - ], - - /* Folder for test artifacts such as screenshots, videos, traces, etc. */ - // outputDir: 'test-results/', - - /* Run your local dev server before starting the tests */ - webServer: { - /** - * Use the dev server by default for faster feedback loop. - * Use the preview server on CI for more realistic testing. - * Playwright will re-use the local server if there is already a dev-server running. - */ - command: process.env.CI ? 'npm run preview' : 'npm run dev', - port: process.env.CI ? 4173 : 5173, - reuseExistingServer: !process.env.CI, - }, -}) diff --git a/web/public/favicon.ico b/web/public/favicon.ico deleted file mode 100644 index df36fcfb72584e00488330b560ebcf34a41c64c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S diff --git a/web/src/App.vue b/web/src/App.vue deleted file mode 100644 index 7905b05..0000000 --- a/web/src/App.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/web/src/assets/base.css b/web/src/assets/base.css deleted file mode 100644 index 8816868..0000000 --- a/web/src/assets/base.css +++ /dev/null @@ -1,86 +0,0 @@ -/* color palette from */ -:root { - --vt-c-white: #ffffff; - --vt-c-white-soft: #f8f8f8; - --vt-c-white-mute: #f2f2f2; - - --vt-c-black: #181818; - --vt-c-black-soft: #222222; - --vt-c-black-mute: #282828; - - --vt-c-indigo: #2c3e50; - - --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); - --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); - --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); - --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); - - --vt-c-text-light-1: var(--vt-c-indigo); - --vt-c-text-light-2: rgba(60, 60, 60, 0.66); - --vt-c-text-dark-1: var(--vt-c-white); - --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); -} - -/* semantic color variables for this project */ -:root { - --color-background: var(--vt-c-white); - --color-background-soft: var(--vt-c-white-soft); - --color-background-mute: var(--vt-c-white-mute); - - --color-border: var(--vt-c-divider-light-2); - --color-border-hover: var(--vt-c-divider-light-1); - - --color-heading: var(--vt-c-text-light-1); - --color-text: var(--vt-c-text-light-1); - - --section-gap: 160px; -} - -@media (prefers-color-scheme: dark) { - :root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); - - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); - - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); - } -} - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - font-weight: normal; -} - -body { - min-height: 100vh; - color: var(--color-text); - background: var(--color-background); - transition: - color 0.5s, - background-color 0.5s; - line-height: 1.6; - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - Roboto, - Oxygen, - Ubuntu, - Cantarell, - 'Fira Sans', - 'Droid Sans', - 'Helvetica Neue', - sans-serif; - font-size: 15px; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} diff --git a/web/src/assets/logo.svg b/web/src/assets/logo.svg deleted file mode 100644 index 7565660..0000000 --- a/web/src/assets/logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/src/assets/main.css b/web/src/assets/main.css deleted file mode 100644 index 36fb845..0000000 --- a/web/src/assets/main.css +++ /dev/null @@ -1,35 +0,0 @@ -@import './base.css'; - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - font-weight: normal; -} - -a, -.green { - text-decoration: none; - color: hsla(160, 100%, 37%, 1); - transition: 0.4s; - padding: 3px; -} - -@media (hover: hover) { - a:hover { - background-color: hsla(160, 100%, 37%, 0.2); - } -} - -@media (min-width: 1024px) { - body { - display: flex; - place-items: center; - } - - #app { - display: grid; - grid-template-columns: 1fr 1fr; - padding: 0 2rem; - } -} diff --git a/web/src/components/HelloWorld.vue b/web/src/components/HelloWorld.vue deleted file mode 100644 index d174cf8..0000000 --- a/web/src/components/HelloWorld.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/web/src/components/TheWelcome.vue b/web/src/components/TheWelcome.vue deleted file mode 100644 index 6092dff..0000000 --- a/web/src/components/TheWelcome.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - diff --git a/web/src/components/WelcomeItem.vue b/web/src/components/WelcomeItem.vue deleted file mode 100644 index 6d7086a..0000000 --- a/web/src/components/WelcomeItem.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - diff --git a/web/src/components/__tests__/HelloWorld.spec.ts b/web/src/components/__tests__/HelloWorld.spec.ts deleted file mode 100644 index 2533202..0000000 --- a/web/src/components/__tests__/HelloWorld.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { mount } from '@vue/test-utils' -import HelloWorld from '../HelloWorld.vue' - -describe('HelloWorld', () => { - it('renders properly', () => { - const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } }) - expect(wrapper.text()).toContain('Hello Vitest') - }) -}) diff --git a/web/src/components/icons/IconCommunity.vue b/web/src/components/icons/IconCommunity.vue deleted file mode 100644 index 2dc8b05..0000000 --- a/web/src/components/icons/IconCommunity.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconDocumentation.vue b/web/src/components/icons/IconDocumentation.vue deleted file mode 100644 index 6d4791c..0000000 --- a/web/src/components/icons/IconDocumentation.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconEcosystem.vue b/web/src/components/icons/IconEcosystem.vue deleted file mode 100644 index c3a4f07..0000000 --- a/web/src/components/icons/IconEcosystem.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconSupport.vue b/web/src/components/icons/IconSupport.vue deleted file mode 100644 index 7452834..0000000 --- a/web/src/components/icons/IconSupport.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/web/src/components/icons/IconTooling.vue b/web/src/components/icons/IconTooling.vue deleted file mode 100644 index 660598d..0000000 --- a/web/src/components/icons/IconTooling.vue +++ /dev/null @@ -1,19 +0,0 @@ - - diff --git a/web/src/main.ts b/web/src/main.ts deleted file mode 100644 index 5dcad83..0000000 --- a/web/src/main.ts +++ /dev/null @@ -1,14 +0,0 @@ -import './assets/main.css' - -import { createApp } from 'vue' -import { createPinia } from 'pinia' - -import App from './App.vue' -import router from './router' - -const app = createApp(App) - -app.use(createPinia()) -app.use(router) - -app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts deleted file mode 100644 index 3e49915..0000000 --- a/web/src/router/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createRouter, createWebHistory } from 'vue-router' -import HomeView from '../views/HomeView.vue' - -const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - name: 'home', - component: HomeView, - }, - { - path: '/about', - name: 'about', - // route level code-splitting - // this generates a separate chunk (About.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => import('../views/AboutView.vue'), - }, - ], -}) - -export default router diff --git a/web/src/stores/counter.ts b/web/src/stores/counter.ts deleted file mode 100644 index b6757ba..0000000 --- a/web/src/stores/counter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ref, computed } from 'vue' -import { defineStore } from 'pinia' - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0) - const doubleCount = computed(() => count.value * 2) - function increment() { - count.value++ - } - - return { count, doubleCount, increment } -}) diff --git a/web/src/views/AboutView.vue b/web/src/views/AboutView.vue deleted file mode 100644 index 756ad2a..0000000 --- a/web/src/views/AboutView.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue deleted file mode 100644 index d5c0217..0000000 --- a/web/src/views/HomeView.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json deleted file mode 100644 index 913b8f2..0000000 --- a/web/tsconfig.app.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], - "exclude": ["src/**/__tests__/*"], - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - - "paths": { - "@/*": ["./src/*"] - } - } -} diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index 100cf6a..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "files": [], - "references": [ - { - "path": "./tsconfig.node.json" - }, - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.vitest.json" - } - ] -} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json deleted file mode 100644 index a83dfc9..0000000 --- a/web/tsconfig.node.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "@tsconfig/node22/tsconfig.json", - "include": [ - "vite.config.*", - "vitest.config.*", - "cypress.config.*", - "nightwatch.conf.*", - "playwright.config.*", - "eslint.config.*" - ], - "compilerOptions": { - "noEmit": true, - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - - "module": "ESNext", - "moduleResolution": "Bundler", - "types": ["node"] - } -} diff --git a/web/tsconfig.vitest.json b/web/tsconfig.vitest.json deleted file mode 100644 index 7d1d8ce..0000000 --- a/web/tsconfig.vitest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.app.json", - "include": ["src/**/__tests__/*", "env.d.ts"], - "exclude": [], - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", - - "lib": [], - "types": ["node", "jsdom"] - } -} diff --git a/web/vite.config.ts b/web/vite.config.ts deleted file mode 100644 index d49d708..0000000 --- a/web/vite.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { fileURLToPath, URL } from 'node:url' - -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' -import vueJsx from '@vitejs/plugin-vue-jsx' -import vueDevTools from 'vite-plugin-vue-devtools' - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [ - vue(), - vueJsx(), - vueDevTools(), - ], - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - }, - }, -}) diff --git a/web/vitest.config.ts b/web/vitest.config.ts deleted file mode 100644 index c328717..0000000 --- a/web/vitest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { mergeConfig, defineConfig, configDefaults } from 'vitest/config' -import viteConfig from './vite.config' - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'jsdom', - exclude: [...configDefaults.exclude, 'e2e/**'], - root: fileURLToPath(new URL('./', import.meta.url)), - }, - }), -) diff --git a/webapi/__init__.py b/webapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webapi/bootstrap/application.py b/webapi/bootstrap/application.py new file mode 100644 index 0000000..3e1e930 --- /dev/null +++ b/webapi/bootstrap/application.py @@ -0,0 +1,88 @@ +import logging +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +from webapi.providers import common +from webapi.providers.logger import register_logger +from webapi.providers import router +from webapi.providers import database +from webapi.providers import probes +from webapi.providers import metrics + +# from webapi.providers import scheduler +from webapi.providers import exception_handler +from webapi.providers import middleware +from .freeleaps_app import FreeleapsApp +from common.config.app_settings import app_settings + +def create_app() -> FastAPI: + logging.info("App initializing") + + app = FreeleapsApp() + + register_logger() + # 1. Register middleware firstly + register(app, middleware) + + # 2. Register other providers + register(app, exception_handler) + register(app, database) + register(app, router) + # register(app, scheduler) + register(app, common) + + # Call the custom_openapi function to change the OpenAPI version + customize_openapi_security(app) + + # Create probes manager to register probes if enabled + if app_settings.PROBES_ENABLED: + register(app, probes) + + # Register metrics APIs if enabled + if app_settings.METRICS_ENABLED: + register(app, metrics) + + return app + + +# This function overrides the OpenAPI schema version to 3.0.0 +def customize_openapi_security(app: FastAPI) -> None: + + def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + # Generate OpenAPI schema + openapi_schema = get_openapi( + title="FreeLeaps API", + version="3.1.0", + description="FreeLeaps API Documentation", + routes=app.routes, + ) + + # Ensure the components section exists in the OpenAPI schema + if "components" not in openapi_schema: + openapi_schema["components"] = {} + + # Add security scheme to components + openapi_schema["components"]["securitySchemes"] = { + "bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} + } + + # Add security requirement globally + openapi_schema["security"] = [{"bearerAuth": []}] + + app.openapi_schema = openapi_schema + return app.openapi_schema + + app.openapi = custom_openapi + + +def register(app, provider): + logging.info(provider.__name__ + " registering") + provider.register(app) + + +def boot(app, provider): + logging.info(provider.__name__ + " booting") + provider.boot(app) diff --git a/webapi/bootstrap/freeleaps_app.py b/webapi/bootstrap/freeleaps_app.py new file mode 100644 index 0000000..496633a --- /dev/null +++ b/webapi/bootstrap/freeleaps_app.py @@ -0,0 +1,6 @@ +from fastapi import FastAPI + + +class FreeleapsApp(FastAPI): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) diff --git a/webapi/config/site_settings.py b/webapi/config/site_settings.py new file mode 100644 index 0000000..43d05d6 --- /dev/null +++ b/webapi/config/site_settings.py @@ -0,0 +1,25 @@ +import os + +from pydantic_settings import BaseSettings + + +class SiteSettings(BaseSettings): + NAME: str = "appname" + DEBUG: bool = True + + ENV: str = "dev" + + SERVER_HOST: str = "0.0.0.0" + SERVER_PORT: int = 8103 + + URL: str = "http://localhost" + TIME_ZONE: str = "UTC" + + BASE_PATH: str = os.path.dirname(os.path.dirname((os.path.abspath(__file__)))) + + class Config: + env_file = ".devbase-webapi.env" + env_file_encoding = "utf-8" + + +site_settings = SiteSettings() diff --git a/webapi/main.py b/webapi/main.py new file mode 100755 index 0000000..d325986 --- /dev/null +++ b/webapi/main.py @@ -0,0 +1,32 @@ +from webapi.bootstrap.application import create_app +from webapi.config.site_settings import site_settings +from fastapi.responses import RedirectResponse +import uvicorn +from typing import Any + + +app = create_app() + + +@app.get("/", status_code=301) +async def root(): + """ + TODO: redirect client to /doc# + """ + return RedirectResponse("docs") + + +if __name__ == "__main__": + uvicorn.run( + app="main:app", host=site_settings.SERVER_HOST, port=site_settings.SERVER_PORT + ) + + +def get_context() -> Any: + # Define your context function. This is where you can set up authentication, database connections, etc. + return {} + + +def get_root_value() -> Any: + # Define your root value function. This can be used to customize the root value for GraphQL operations. + return {} diff --git a/webapi/middleware/__init__.py b/webapi/middleware/__init__.py new file mode 100644 index 0000000..d1b6f8b --- /dev/null +++ b/webapi/middleware/__init__.py @@ -0,0 +1,4 @@ +from .freeleaps_auth_middleware import FreeleapsAuthMiddleware +from .database_middleware import DatabaseMiddleware + +__all__ = ['FreeleapsAuthMiddleware', 'DatabaseMiddleware'] \ No newline at end of file diff --git a/webapi/middleware/database_middleware.py b/webapi/middleware/database_middleware.py new file mode 100644 index 0000000..6a71b72 --- /dev/null +++ b/webapi/middleware/database_middleware.py @@ -0,0 +1,89 @@ +from fastapi import Request, status, HTTPException +from fastapi.responses import JSONResponse +from webapi.middleware.freeleaps_auth_middleware import request_context_var +from common.log.module_logger import ModuleLogger +from backend.models.base_doc import BaseDoc + + +class DatabaseMiddleware: + def __init__(self, app): + self.app = app + self.module_logger = ModuleLogger(sender_id=DatabaseMiddleware) + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + + request = Request(scope, receive) + + # Get tenant id from auth context (set by FreeleapsAuthMiddleware) + product_id = None + try: + ctx = request_context_var.get() + product_id = getattr(ctx, "product_id", None) + await self.module_logger.log_info(f"Retrieved product_id from auth context: {product_id}") + except Exception as e: + await self.module_logger.log_error(f"Failed to get auth context: {str(e)}") + product_id = None + + # Get tenant cache and main database from app state + try: + tenant_cache = request.app.state.tenant_cache + main_db = request.app.state.main_db + await self.module_logger.log_info(f"Retrieved app state - tenant_cache: {'success' if tenant_cache is not None else 'fail'}, main_db: {'success' if main_db is not None else 'fail'}") + except Exception as e: + await self.module_logger.log_error(f"Failed to get app state: {str(e)}") + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Database not properly initialized"} + ) + return await response(scope, receive, send) + + if not product_id: + # Compatibility / public routes: use main database + await self.module_logger.log_info(f"No product_id - using main database for path: {request.url.path}") + + # Get main database (no Beanie initialization needed with BaseDoc) + main_db_initialized = await tenant_cache.get_main_db_initialized() + + request.state.db = main_db_initialized + request.state.product_id = None + + # Set the database context for BaseDoc models + BaseDoc.set_tenant_database(main_db_initialized) + + await self.module_logger.log_info(f"Successfully initialized main database") + return await self.app(scope, receive, send) + + try: + # Get tenant-specific database (no Beanie initialization needed with BaseDoc) + await self.module_logger.log_info(f"Attempting to get tenant database for product_id: {product_id}") + tenant_db = await tenant_cache.get_initialized_db(product_id) + + request.state.db = tenant_db + request.state.product_id = product_id + + # Set the database context for BaseDoc models + BaseDoc.set_tenant_database(tenant_db) + + await self.module_logger.log_info(f"Successfully retrieved tenant database for product_id: {product_id}") + return await self.app(scope, receive, send) + + except ValueError as e: + # Handle tenant not found or inactive (ValueError from TenantDBCache) + await self.module_logger.log_error(f"Tenant error for {product_id}: {str(e)}") + response = JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": str(e)} + ) + return await response(scope, receive, send) + + except Exception as e: + await self.module_logger.log_error(f"Database error for tenant {product_id}: {str(e)}") + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Database connection error"} + ) + return await response(scope, receive, send) + + return await self.app(scope, receive, send) \ No newline at end of file diff --git a/webapi/middleware/freeleaps_auth_middleware.py b/webapi/middleware/freeleaps_auth_middleware.py new file mode 100644 index 0000000..498bb1d --- /dev/null +++ b/webapi/middleware/freeleaps_auth_middleware.py @@ -0,0 +1,191 @@ +import httpx +import asyncio +import time +import contextvars +from datetime import datetime +from starlette.requests import Request +from fastapi import HTTPException, Response +from typing import Dict, Any, Optional +from common.log.module_logger import ModuleLogger + +from backend.models.user.models import UsageLogDoc +from backend.infra.api_key_introspect_handler import ApiKeyIntrospectHandler + +# Define context data class +class RequestContext: + def __init__(self, tenant_name: str = None, product_id: str = None, key_id: str = None): + self.tenant_name = tenant_name + self.product_id = product_id + self.key_id = key_id + + def __repr__(self): + return f"RequestContext(tenant_name='{self.tenant_name}', product_id='{self.product_id}', key_id='{self.key_id}')" + +# Create context variable, store RequestContext object +request_context_var = contextvars.ContextVar('request_context', default=RequestContext()) + +class FreeleapsAuthMiddleware: + """ + Notification service API Key middleware + """ + + def __init__(self, app): + self.app = app + self.api_key_introspect_handler = ApiKeyIntrospectHandler() + self.module_logger = ModuleLogger(sender_id=FreeleapsAuthMiddleware) + + async def __call__(self, scope, receive, send): + """ + Middleware main function, execute before and after request processing + """ + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive) + start_time = time.time() + validation_result = None + + try: + # 1. Skip paths that do not need validation + if self._should_skip_validation(request.url.path): + await self.module_logger.log_info(f"Path skipped validation: {request.url.path}") + await self.app(scope, receive, send) + return + + # 2. Extract API Key from request header + api_key = request.headers.get("X-API-KEY") + # if the API_KEY field is empty, the request can be processed directly without validation. + # for compatibility + if not api_key or api_key == "": + await self.module_logger.log_info(f"API Key is empty: {request.url.path}") + await self.app(scope, receive, send) + return + + # 3. Call freeleaps_auth to validate API Key + validation_result = await self.api_key_introspect_handler.api_key_introspect(api_key) + + # 4. Store validation result in contextvars for later use + request_context = RequestContext( + tenant_name=validation_result.get("tenant_name"), + product_id=validation_result.get("product_id"), + key_id=validation_result.get("key_id") + ) + request_context_var.set(request_context) + + # 6. Process request and capture response + response_captured = None + + async def send_wrapper(message): + nonlocal response_captured + if message["type"] == "http.response.start": + # Convert bytes headers to string headers + headers = {} + for header_name, header_value in message.get("headers", []): + if isinstance(header_name, bytes): + header_name = header_name.decode('latin-1') + if isinstance(header_value, bytes): + header_value = header_value.decode('latin-1') + headers[header_name] = header_value + + response_captured = Response( + status_code=message["status"], + headers=headers, + media_type="application/json" + ) + await send(message) + + await self.app(scope, receive, send_wrapper) + + # 7. Record usage log after request processing + if response_captured: + await self._log_usage(validation_result, request, response_captured, start_time) + + except HTTPException as http_exc: + # Pass through HTTP exceptions (401, 403, etc.) from auth service + await self.module_logger.log_info(f"API Key validation failed: {http_exc.status_code} - {http_exc.detail}") + response = Response( + status_code=http_exc.status_code, + content=f'{{"error": "Authentication failed", "message": "{str(http_exc.detail)}"}}', + media_type="application/json" + ) + await response(scope, receive, send) + except Exception as e: + await self.module_logger.log_error(f"Middleware error: {str(e)}") + response = Response( + status_code=500, + content=f'{{"error": "Internal error", "message": "Failed to process request", "details": "{str(e)}"}}', + media_type="application/json" + ) + await response(scope, receive, send) + + def _should_skip_validation(self, path: str) -> bool: + """ + Check if the path should be skipped for validation + """ + skip_paths = [ + "/api/_/healthz", # Health check endpoint + "/api/_/readyz", # Readiness check endpoint + "/api/_/livez", # Liveness check endpoint + "/metrics", # Metrics endpoint + "/docs", # API documentation + "/openapi.json", # OpenAPI specification + "/favicon.ico" # Website icon + ] + + # Check exact match for root path + if path == "/": + return True + + # Check startswith for other paths + return any(path.startswith(skip_path) for skip_path in skip_paths) + + async def _log_usage(self, validation_result: Dict[str, Any], request: Request, + response: Response, start_time: float) -> None: + """ + Record API usage log + """ + try: + # calculate processing time + process_time = (time.time() - start_time) * 1000 + + # get request body size + try: + request_body = await request.body() + bytes_in = len(request_body) if request.method in ["POST", "PUT", "PATCH"] else 0 + except Exception: + bytes_in = 0 + + bytes_out = 0 + if hasattr(response, 'headers'): + content_length = response.headers.get('content-length') + if content_length: + bytes_out = int(content_length) + + # create usage log document + usage_log_doc = UsageLogDoc( + timestamp=datetime.utcnow(), + tenant_id=validation_result.get("tenant_name"), + operation=f"{request.method} {request.url.path}", + request_id=request.headers.get("X-Request-ID", "unknown"), + status="success" if response.status_code < 400 else "error", + latency_ms=int(process_time), + bytes_in=bytes_in, + bytes_out=bytes_out, + key_id=validation_result.get("key_id"), + extra={ + "tenant_name": request_context_var.get().tenant_name, + "product_id": request_context_var.get().product_id, + "scopes": validation_result.get("scopes"), + "user_agent": request.headers.get("User-Agent"), + "ip_address": request.client.host if request.client else "unknown", + "response_status": response.status_code + } + ) + + # save to database + await usage_log_doc.save() + await self.module_logger.log_info(f"API Usage logged: {usage_log_doc.operation} for tenant {usage_log_doc.tenant_id}") + + except Exception as e: + await self.module_logger.log_error(f"Failed to log usage: {str(e)}") diff --git a/webapi/providers/common.py b/webapi/providers/common.py new file mode 100644 index 0000000..1dd849f --- /dev/null +++ b/webapi/providers/common.py @@ -0,0 +1,31 @@ +from fastapi.middleware.cors import CORSMiddleware +from webapi.config.site_settings import site_settings + + +def register(app): + app.debug = site_settings.DEBUG + app.title = site_settings.NAME + + add_global_middleware(app) + + # This hook ensures that a connection is opened to handle any queries + # generated by the request. + @app.on_event("startup") + def startup(): + pass + + # This hook ensures that the connection is closed when we've finished + # processing the request. + @app.on_event("shutdown") + def shutdown(): + pass + + +def add_global_middleware(app): + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/webapi/providers/database.py b/webapi/providers/database.py new file mode 100644 index 0000000..b7d33a1 --- /dev/null +++ b/webapi/providers/database.py @@ -0,0 +1,241 @@ +from webapi.config.site_settings import site_settings +from fastapi import HTTPException +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase +from backend.models.user.models import ( + UserAccountDoc, + UserPasswordDoc, + UserEmailDoc, + UserMobileDoc, + AuthCodeDoc, + UsageLogDoc +) +from backend.models.user_profile.models import BasicProfileDoc, ProviderProfileDoc +from backend.models.permission.models import PermissionDoc, RoleDoc, UserRoleDoc +from common.config.app_settings import app_settings +from common.log.module_logger import ModuleLogger +from common.probes import ProbeResult +import asyncio +from collections import OrderedDict +from typing import Optional, Union +import os + + +# Global variables for database management +MAIN_CLIENT: Optional[AsyncIOMotorClient] = None +TENANT_CACHE: Optional['TenantDBCache'] = None + +# Define document models +document_models = [ + UsageLogDoc, + UserAccountDoc, + UserPasswordDoc, + UserEmailDoc, + UserMobileDoc, + AuthCodeDoc, + BasicProfileDoc, + ProviderProfileDoc, + PermissionDoc, + RoleDoc, + UserRoleDoc +] + +tenant_document_models = [ + UserAccountDoc, + UserPasswordDoc, + UserEmailDoc, + UserMobileDoc, + AuthCodeDoc, + BasicProfileDoc, + ProviderProfileDoc, + PermissionDoc, + RoleDoc, + UserRoleDoc +] + +class TenantDBCache: + """ + Enhanced tenant database cache that caches only clients, not databases. + product_id -> AsyncIOMotorClient + Uses main_db.tenant_doc to resolve mongodb_uri; caches clients with LRU. + Database instances are created fresh each time from cached clients. + """ + + def __init__(self, main_db: AsyncIOMotorDatabase, max_size: int = 64): + self.main_db = main_db + self.max_size = max_size + self._cache: "OrderedDict[str, AsyncIOMotorClient]" = OrderedDict() + self._locks: dict[str, asyncio.Lock] = {} + self._global_lock = asyncio.Lock() + self.module_logger = ModuleLogger(sender_id="TenantDBCache") + + async def get_initialized_db(self, product_id: str) -> AsyncIOMotorDatabase: + """Get tenant database (no Beanie initialization needed with BaseDoc)""" + + # fast-path: check if client is cached + cached_client = self._cache.get(product_id) + if cached_client: + await self.module_logger.log_info(f"Found cached client for {product_id}") + self._cache.move_to_end(product_id) + + # Get fresh database instance from cached client + db = cached_client.get_default_database() + if db is not None: + await self.module_logger.log_info(f"Using cached client for {product_id}") + return db + else: + await self.module_logger.log_error(f"No default database found for cached client {product_id}") + # Remove invalid cached client + del self._cache[product_id] + + # double-checked under per-tenant lock + lock = self._locks.setdefault(product_id, asyncio.Lock()) + async with lock: + cached_client = self._cache.get(product_id) + if cached_client: + await self.module_logger.log_info(f"Double-check found cached client for {product_id}") + self._cache.move_to_end(product_id) + + # Get fresh database instance from cached client + db = cached_client.get_default_database() + if db is not None: + await self.module_logger.log_info(f"Using cached client for {product_id} (double-check)") + return db + else: + await self.module_logger.log_error(f"No default database found for cached client {product_id}") + # Remove invalid cached client + del self._cache[product_id] + + # Create new tenant connection - use raw MongoDB query since we don't have TenantDoc model + """ + tenant_doc content: + { + "tenant_name": "magicleaps", + "product_id": "68a3f19119cfaf36316f6d14", + "mongodb_uri": "mongodb://localhost:27017/interview", + "status": "active" + } + """ + tenant = await self.main_db["tenant_doc"].find_one({"product_id": product_id}) + if not tenant: + await self.module_logger.log_error(f"Tenant {product_id} does not exist in main database") + raise HTTPException( + status_code=404, + detail=f"Tenant {product_id} does not exist", + headers={"X-Error-Message": f"Tenant {product_id} does not exist"} + ) + if tenant.get("status") != "active": + await self.module_logger.log_error(f"Tenant {product_id} is not active, status: {tenant.get('status')}") + raise HTTPException( + status_code=403, + detail=f"Tenant {product_id} is not active", + headers={"X-Error-Message": f"Tenant {product_id} is not active, status: {tenant.get('status')}"} + ) + + uri = tenant["mongodb_uri"] + client = AsyncIOMotorClient(uri, minPoolSize=3, maxPoolSize=20, serverSelectionTimeoutMS=10000) + + # robust db name resolution (get_default_database handles mongodb+srv and empty paths) + default_db = client.get_default_database() + if default_db is not None: + db = default_db + await self.module_logger.log_info(f"Using default database for tenant {product_id}: {db.name}") + else: + await self.module_logger.log_error(f"No default database found for tenant {product_id}") + raise HTTPException( + status_code=500, + detail=f"No default database found for tenant {product_id}", + headers={"X-Error-Message": f"No default database found for tenant {product_id}"} + ) + + # Cache only the client + await self._lru_put(product_id, client) + await self.module_logger.log_info(f"Tenant client {product_id} cached successfully") + return db + + async def get_main_db_initialized(self) -> AsyncIOMotorDatabase: + """Get main database (no Beanie initialization needed with BaseDoc)""" + await self.module_logger.log_info("Main database ready (using BaseDoc)") + return self.main_db + + async def _lru_put(self, key: str, client: AsyncIOMotorClient): + async with self._global_lock: + self._cache[key] = client + self._cache.move_to_end(key) + if len(self._cache) > self.max_size: + old_key, old_client = self._cache.popitem(last=False) + await self.module_logger.log_info(f"Cache full, removing LRU tenant: {old_key}") + try: + old_client.close() + await self.module_logger.log_info(f"Closed connection for evicted tenant: {old_key}") + except Exception as e: + await self.module_logger.log_error(f"Error closing connection for {old_key}: {str(e)}") + self._locks.pop(old_key, None) + + async def aclose(self): + async with self._global_lock: + for key, client in self._cache.items(): + try: + client.close() + await self.module_logger.log_info(f"Closed connection for tenant: {key}") + except Exception as e: + await self.module_logger.log_error(f"Error closing connection for {key}: {str(e)}") + self._cache.clear() + self._locks.clear() + await self.module_logger.log_info("Tenant cache cleared successfully") + + +def register(app): + """Register database-related configurations and setup""" + app.debug = site_settings.DEBUG + app.title = site_settings.NAME + + @app.on_event("startup") + async def start_database(): + await initiate_database(app) + + @app.on_event("shutdown") + async def shutdown_database(): + await cleanup_database() + +async def check_database_initialized() -> ProbeResult: + try: + await asyncio.wait_for(MAIN_CLIENT.server_info(), timeout=5) + return ProbeResult(success=True, message="service has been initialized and ready to serve") + except Exception: + return ProbeResult(success=False, message="service is not initialized yet", data={"error": "database is not ready"}) + + +async def initiate_database(app): + """Initialize main database and tenant cache""" + global MAIN_CLIENT, TENANT_CACHE + + module_logger = ModuleLogger(sender_id="DatabaseInit") + + # 1) Create main/catalog client + DB + MAIN_CLIENT = AsyncIOMotorClient(app_settings.MONGODB_URI) + main_db = MAIN_CLIENT[app_settings.MONGODB_NAME] + + # 2) Create tenant cache that uses main_db lookups to resolve product_id -> tenant db + max_cache_size = getattr(app_settings, 'TENANT_CACHE_MAX', 64) + TENANT_CACHE = TenantDBCache(main_db, max_size=max_cache_size) + + # 3) Store on app state for middleware to access + app.state.main_db = main_db + app.state.tenant_cache = TENANT_CACHE + + await module_logger.log_info("Database and tenant cache initialized successfully") + + +async def cleanup_database(): + """Cleanup database connections and cache""" + global MAIN_CLIENT, TENANT_CACHE + + module_logger = ModuleLogger(sender_id="DatabaseCleanup") + + if TENANT_CACHE: + await TENANT_CACHE.aclose() + + if MAIN_CLIENT: + MAIN_CLIENT.close() + + await module_logger.log_info("Database connections closed successfully") \ No newline at end of file diff --git a/webapi/providers/exception_handler.py b/webapi/providers/exception_handler.py new file mode 100644 index 0000000..fd8cf43 --- /dev/null +++ b/webapi/providers/exception_handler.py @@ -0,0 +1,47 @@ +from bson.errors import InvalidId +from fastapi import FastAPI, HTTPException +from fastapi.exceptions import RequestValidationError +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_422_UNPROCESSABLE_ENTITY, + HTTP_500_INTERNAL_SERVER_ERROR, +) + + +async def custom_http_exception_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=exc.status_code, + content={"error": exc.detail}, + ) + + + +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=HTTP_400_BAD_REQUEST, + content={"error": str(exc)}, + ) + +async def validation_error_exception_handler(request: Request, exc: InvalidId): + return JSONResponse( + status_code=HTTP_400_BAD_REQUEST, + content={"error": str(exc)}, + ) + +async def exception_handler(request: Request, exc: Exception): + return JSONResponse( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + content={"error": str(exc)}, + ) + + +def register(app: FastAPI): + app.add_exception_handler(HTTPException, custom_http_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(InvalidId, exception_handler) + app.add_exception_handler(Exception, exception_handler) diff --git a/webapi/providers/logger.py b/webapi/providers/logger.py new file mode 100644 index 0000000..6eb1e22 --- /dev/null +++ b/webapi/providers/logger.py @@ -0,0 +1,8 @@ +from loguru import logger as guru_logger +from common.log.base_logger import LoggerBase + + +def register_logger(): + print("📢 Setting up logging interception...") + LoggerBase.configure_uvicorn_logging() + print("✅ Logging interception complete. Logs are formatted and deduplicated!") diff --git a/webapi/providers/metrics.py b/webapi/providers/metrics.py new file mode 100644 index 0000000..ae5a634 --- /dev/null +++ b/webapi/providers/metrics.py @@ -0,0 +1,13 @@ +import logging +from prometheus_fastapi_instrumentator import Instrumentator +from common.config.app_settings import app_settings + +def register(app): + instrumentator = Instrumentator().instrument(app, + metric_namespace="freeleaps", + metric_subsystem=app_settings.APP_NAME) + + @app.on_event("startup") + async def startup(): + instrumentator.expose(app, endpoint="/api/_/metrics", should_gzip=True) + logging.info("Metrics endpoint exposed at /api/_/metrics") \ No newline at end of file diff --git a/webapi/providers/middleware.py b/webapi/providers/middleware.py new file mode 100644 index 0000000..f11c216 --- /dev/null +++ b/webapi/providers/middleware.py @@ -0,0 +1,11 @@ +from webapi.middleware.freeleaps_auth_middleware import FreeleapsAuthMiddleware +from webapi.middleware.database_middleware import DatabaseMiddleware + + +def register(app): + """ + Register middleware to FastAPI application + """ + # Register middlewares + app.add_middleware(DatabaseMiddleware) + app.add_middleware(FreeleapsAuthMiddleware) \ No newline at end of file diff --git a/webapi/providers/probes.py b/webapi/providers/probes.py new file mode 100644 index 0000000..d7b0dac --- /dev/null +++ b/webapi/providers/probes.py @@ -0,0 +1,20 @@ +from common.probes import ProbeManager, ProbeType +from common.probes.adapters import FastAPIAdapter + +def register(app): + probes_manager = ProbeManager() + probes_manager.register_adapter("fastapi", FastAPIAdapter(app)) + + probes_manager.register( + name="readiness", + prefix="/api", + type=ProbeType.READINESS, + frameworks=["fastapi"] + ) + + probes_manager.register(name="liveness", prefix="/api", type=ProbeType.LIVENESS, frameworks=["fastapi"]) + probes_manager.register(name="startup", prefix="/api", type=ProbeType.STARTUP, frameworks=["fastapi"]) + + @app.on_event("startup") + async def mark_startup_complete(): + probes_manager.mark_startup_complete() \ No newline at end of file diff --git a/webapi/providers/router.py b/webapi/providers/router.py new file mode 100644 index 0000000..3ad11ae --- /dev/null +++ b/webapi/providers/router.py @@ -0,0 +1,34 @@ +from webapi.routes import api_router + +from starlette import routing + + +def register(app): + app.include_router( + api_router, + prefix="/api", + tags=["api"], + dependencies=[], + responses={404: {"description": "no page found"}}, + ) + + if app.debug: + for route in app.routes: + if not isinstance(route, routing.WebSocketRoute): + print( + { + "path": route.path, + "endpoint": route.endpoint, + "name": route.name, + "methods": route.methods, + } + ) + else: + print( + { + "path": route.path, + "endpoint": route.endpoint, + "name": route.name, + "type": "web socket route", + } + ) diff --git a/webapi/providers/scheduler.py b/webapi/providers/scheduler.py new file mode 100644 index 0000000..7ea8d6c --- /dev/null +++ b/webapi/providers/scheduler.py @@ -0,0 +1,8 @@ +import asyncio + + +def register(app): + @app.on_event("startup") + async def start_scheduler(): + #create your scheduler here + pass diff --git a/webapi/routes/__init__.py b/webapi/routes/__init__.py new file mode 100644 index 0000000..2c8a9ee --- /dev/null +++ b/webapi/routes/__init__.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter +from .signin import router as signin_router +from .tokens import router as token_router +from .auth import router as auth_router +from .permission import router as permission_router +from .role import router as role_router +from .user import router as user_router + +api_router = APIRouter(prefix="/auth") +api_router.include_router(signin_router, tags=["signin"]) +api_router.include_router(token_router, tags=["token"]) +api_router.include_router(auth_router, tags=["auth"]) +api_router.include_router(permission_router, tags=["permission"]) +api_router.include_router(role_router, tags=["role"]) +api_router.include_router(user_router, tags=["user"]) +websocket_router = APIRouter() diff --git a/webapi/routes/api.py b/webapi/routes/api.py new file mode 100644 index 0000000..6545552 --- /dev/null +++ b/webapi/routes/api.py @@ -0,0 +1,15 @@ +from fastapi.routing import APIRoute +from starlette import routing + + +def post_process_router(app) -> None: + """ + Simplify operation IDs so that generated API clients have simpler function + names. + + Should be called only after all routes have been added. + """ + for route in app.routes: + if isinstance(route, APIRoute): + if hasattr(route, "operation_id"): + route.operation_id = route.name # in this case, 'read_items' diff --git a/webapi/routes/auth/__init__.py b/webapi/routes/auth/__init__.py new file mode 100644 index 0000000..c787ab7 --- /dev/null +++ b/webapi/routes/auth/__init__.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter +from .send_email_code import router as sec_router +from .send_mobile_code import router as smc_router + + +router = APIRouter() + +router.include_router(sec_router, prefix="/code", tags=["authentication"]) +router.include_router(smc_router, prefix="/code", tags=["authentication"]) diff --git a/webapi/routes/auth/send_email_code.py b/webapi/routes/auth/send_email_code.py new file mode 100644 index 0000000..8e70a64 --- /dev/null +++ b/webapi/routes/auth/send_email_code.py @@ -0,0 +1,41 @@ +from backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from common.token.token_manager import TokenManager, CurrentUser +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, HTTPException +from starlette.status import HTTP_401_UNAUTHORIZED + +router = APIRouter() +token_manager = TokenManager() +# Web API +# send_email_code +# + + +class RequestIn(BaseModel): + email: str + sender_id: str + + +@router.post( + "/send-email-code", + operation_id="send-email-code", + summary="Send a verification code to the specified email address", + description="This API requires an authenticated user and will send a code to the specified email. \ + The code can be used later to verify the email address.", + response_description="Indicates success or failure of the email code send operation", +) +async def send_email_code( + item: RequestIn, + current_user: CurrentUser = Depends(token_manager.get_current_user), +): + user_id = current_user.user_id + + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) + + result = await SignInHub().send_email_code(item.sender_id, item.email) + return JSONResponse(content=jsonable_encoder(result)) diff --git a/webapi/routes/auth/send_mobile_code.py b/webapi/routes/auth/send_mobile_code.py new file mode 100644 index 0000000..4347888 --- /dev/null +++ b/webapi/routes/auth/send_mobile_code.py @@ -0,0 +1,40 @@ +from backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from common.token.token_manager import TokenManager, CurrentUser +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, HTTPException +from starlette.status import HTTP_401_UNAUTHORIZED + +router = APIRouter() +token_manager = TokenManager() +# Web API +# send_email_code +# + + +class RequestIn(BaseModel): + email: str + + +@router.post( + "/send-mobile-code", + operation_id="send-mobile-code", + summary="Send a verification code to the specified mobile number", + description="This API requires an authenticated user and will send a code to the specified mobile. \ + The code can be used later to verify the mobile.", + response_description="Indicates success or failure of the mobile code send operation", +) +async def send_email_code( + item: RequestIn, + current_user: CurrentUser = Depends(token_manager.get_current_user), +): + user_id = current_user.user_id + + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) + + result = await SignInHub().send_mobile_code(item.email) + return JSONResponse(content=jsonable_encoder(result)) diff --git a/webapi/routes/permission/__init__.py b/webapi/routes/permission/__init__.py new file mode 100644 index 0000000..49fb7b2 --- /dev/null +++ b/webapi/routes/permission/__init__.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter +from .create_or_update_permission import router as cup_router +from .create_permission import router as cp_router +from .query_permission import router as qp_router +from .update_permission import router as up_router +from .delete_permission import router as delp_router +from .query_permission_no_pagination import router as qpno_router + + + +router = APIRouter() + +router.include_router(cup_router, prefix="/permission", tags=["permission"]) +router.include_router(cp_router, prefix="/permission", tags=["permission"]) +router.include_router(qp_router, prefix="/permission", tags=["permission"]) +router.include_router(up_router, prefix="/permission", tags=["permission"]) +router.include_router(delp_router, prefix="/permission", tags=["permission"]) +router.include_router(qpno_router, prefix="/permission", tags=["permission"]) \ No newline at end of file diff --git a/webapi/routes/permission/create_or_update_permission.py b/webapi/routes/permission/create_or_update_permission.py new file mode 100644 index 0000000..1216a23 --- /dev/null +++ b/webapi/routes/permission/create_or_update_permission.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional + +from backend.services.permission.permission_service import PermissionService +from common.token.token_manager import TokenManager + + +router = APIRouter() +token_manager = TokenManager() +permission_service = PermissionService() + + +class CreateOrUpdatePermissionRequest(BaseModel): + permission_key: str + permission_name: str + custom_permission_id: Optional[str] = None + description: Optional[str] = None + + +class PermissionResponse(BaseModel): + id: str + permission_key: str + permission_name: str + description: Optional[str] = None + created_at: datetime + updated_at: datetime + + +@router.post( + "/create-or-update", + response_model=PermissionResponse, + operation_id="create-or-update-permission", + summary="Create or Update Permission", + description="Create or update a permission by id." +) +async def create_or_update_permission( + req: CreateOrUpdatePermissionRequest, + #_: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) +) -> PermissionResponse: + doc = await permission_service.create_or_update_permission(req.permission_key, req.permission_name, req.custom_permission_id, + req.description) + return PermissionResponse(**doc.model_dump()) diff --git a/webapi/routes/permission/create_permission.py b/webapi/routes/permission/create_permission.py new file mode 100644 index 0000000..c4ae159 --- /dev/null +++ b/webapi/routes/permission/create_permission.py @@ -0,0 +1,43 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional + +from backend.services.permission.permission_service import PermissionService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +permission_service = PermissionService() + + +class CreatePermissionRequest(BaseModel): + permission_key: str + permission_name: str + description: Optional[str] = None + + +class PermissionResponse(BaseModel): + id: str + permission_key: str + permission_name: str + description: Optional[str] = None + created_at: datetime + updated_at: datetime + + +@router.post( + "/create", + response_model=PermissionResponse, + operation_id="create-permission", + summary="Create Permission", + description="Create a new permission." +) +async def create_permission( + req: CreatePermissionRequest, + #_: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) +) -> PermissionResponse: + doc = await permission_service.create_permission(req.permission_key, req.permission_name, req.description) + + return PermissionResponse(**doc.model_dump()) diff --git a/webapi/routes/permission/delete_permission.py b/webapi/routes/permission/delete_permission.py new file mode 100644 index 0000000..18cc508 --- /dev/null +++ b/webapi/routes/permission/delete_permission.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from backend.services.permission.permission_service import PermissionService +from common.token.token_manager import TokenManager + +token_manager = TokenManager() +router = APIRouter() +permission_service = PermissionService() + + +class DeletePermissionRequest(BaseModel): + permission_id: str + + +class DeletePermissionResponse(BaseModel): + success: bool + + +@router.post( + "/delete", + response_model=DeletePermissionResponse, + operation_id="delete-permission", + summary="Delete Permission", + description="Delete a permission after checking if it is referenced by any role." +) +async def delete_permission( + req: DeletePermissionRequest, + #_: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) +) -> DeletePermissionResponse: + await permission_service.delete_permission(req.permission_id) + return DeletePermissionResponse(success=True) diff --git a/webapi/routes/permission/query_permission.py b/webapi/routes/permission/query_permission.py new file mode 100644 index 0000000..50769dd --- /dev/null +++ b/webapi/routes/permission/query_permission.py @@ -0,0 +1,50 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +from backend.services.permission.permission_service import PermissionService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +permission_service = PermissionService() + +class QueryPermissionRequest(BaseModel): + permission_key: Optional[str] = None + permission_name: Optional[str] = None + page: int = 1 + page_size: int = 10 + +class PermissionResponse(BaseModel): + id: str + permission_key: str + permission_name: str + description: Optional[str] = None + created_at: datetime + updated_at: datetime + +class QueryPermissionResponse(BaseModel): + items: List[PermissionResponse] + total: int + page: int + page_size: int + +@router.post( + "/query", + response_model=QueryPermissionResponse, + operation_id="query-permission", + summary="Query Permissions (paginated)", + description="Query permissions with pagination and fuzzy search. Only Admin role allowed." +) +async def query_permissions( + req: QueryPermissionRequest, +) -> QueryPermissionResponse: + result = await permission_service.query_permissions(req.permission_key, req.permission_name, req.page, req.page_size) + items = [PermissionResponse(**item) for item in result["items"]] + return QueryPermissionResponse( + items=items, + total=result["total"], + page=result["page"], + page_size=result["page_size"] + ) \ No newline at end of file diff --git a/webapi/routes/permission/query_permission_no_pagination.py b/webapi/routes/permission/query_permission_no_pagination.py new file mode 100644 index 0000000..fc2bfba --- /dev/null +++ b/webapi/routes/permission/query_permission_no_pagination.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +from backend.services.permission.permission_service import PermissionService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +permission_service = PermissionService() + +class QueryPermissionNoPaginationRequest(BaseModel): + permission_id: Optional[str] = None + permission_key: Optional[str] = None + permission_name: Optional[str] = None + +class PermissionResponse(BaseModel): + id: str + permission_key: str + permission_name: str + description: Optional[str] = None + created_at: datetime + updated_at: datetime + +class QueryPermissionNoPaginationResponse(BaseModel): + items: List[PermissionResponse] + total: int + +@router.post( + "/query_permission_no_pagination", + response_model=QueryPermissionNoPaginationResponse, + operation_id="query-permission-no-pagination", + summary="Query Permission No Pagination", + description="Query permissions fuzzy search." +) +async def query_permission_no_pagination( + req: QueryPermissionNoPaginationRequest, +) -> QueryPermissionNoPaginationResponse: + result = await permission_service.query_permissions_no_pagination(req.permission_id, req.permission_key, req.permission_name) + items = [PermissionResponse(**item) for item in result["items"]] + return QueryPermissionNoPaginationResponse( + items=items, + total=result["total"] + ) \ No newline at end of file diff --git a/webapi/routes/permission/update_permission.py b/webapi/routes/permission/update_permission.py new file mode 100644 index 0000000..acea937 --- /dev/null +++ b/webapi/routes/permission/update_permission.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional + +from backend.services.permission.permission_service import PermissionService +from common.token.token_manager import TokenManager + + +router = APIRouter() +token_manager = TokenManager() +permission_service = PermissionService() + + +class UpdatePermissionRequest(BaseModel): + permission_id: str + permission_key: str + permission_name: str + description: Optional[str] = None + + +class PermissionResponse(BaseModel): + id: str + permission_key: str + permission_name: str + description: Optional[str] = None + created_at: datetime + updated_at: datetime + + +@router.post( + "/update", + response_model=PermissionResponse, + operation_id="update-permission", + summary="Update Permission", + description="Update an existing permission by id. Only Admin role allowed." +) +async def update_permission( + req: UpdatePermissionRequest, + #_: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) +) -> PermissionResponse: + doc = await permission_service.update_permission(req.permission_id, req.permission_key, req.permission_name, + req.description) + return PermissionResponse(**doc.model_dump()) diff --git a/webapi/routes/role/__init__.py b/webapi/routes/role/__init__.py new file mode 100644 index 0000000..c71537c --- /dev/null +++ b/webapi/routes/role/__init__.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter +from .create_or_update_role import router as create_or_update_role_router +from .create_role import router as create_role_router +from .update_role import router as update_role_router +from .query_role import router as query_role_router +from .query_role_no_pagination import router as query_role_no_pagination_router +from .assign_permissions import router as assign_permissions_router +from .delete_role import router as delete_role_router + +router = APIRouter() + +router.include_router(create_or_update_role_router, prefix="/role", tags=["role"]) +router.include_router(create_role_router, prefix="/role", tags=["role"]) +router.include_router(update_role_router, prefix="/role", tags=["role"]) +router.include_router(query_role_router, prefix="/role", tags=["role"]) +router.include_router(query_role_no_pagination_router, prefix="/role", tags=["role"]) +router.include_router(assign_permissions_router, prefix="/role", tags=["role"]) +router.include_router(delete_role_router, prefix="/role", tags=["role"]) \ No newline at end of file diff --git a/webapi/routes/role/assign_permissions.py b/webapi/routes/role/assign_permissions.py new file mode 100644 index 0000000..e6e6f38 --- /dev/null +++ b/webapi/routes/role/assign_permissions.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import List + +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + +class AssignPermissionsRequest(BaseModel): + role_id: str + permission_ids: List[str] + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: str + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + +@router.post( + "/assign-permissions", + response_model=RoleResponse, + operation_id="assign-permissions-to-role", + summary="Assign Permissions to Role", + description="Assign permissions to a role by updating the permission_ids field." +) +async def assign_permissions_to_role( + req: AssignPermissionsRequest, + #_: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) +) -> RoleResponse: + doc = await role_service.assign_permissions_to_role(req.role_id, req.permission_ids) + return RoleResponse(**doc.model_dump()) \ No newline at end of file diff --git a/webapi/routes/role/create_or_update_role.py b/webapi/routes/role/create_or_update_role.py new file mode 100644 index 0000000..12934cd --- /dev/null +++ b/webapi/routes/role/create_or_update_role.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional, List + +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + + +class CreateOrUpdateRoleRequest(BaseModel): + role_key: str + role_name: str + role_level: int + custom_role_id: Optional[str] = None + role_description: Optional[str] = None + + + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + + +@router.post( + "/create-or-update", + response_model=RoleResponse, + operation_id="create-or-update-role", + summary="Create or Update Role", + description="Create or update a role by id." +) +async def create_or_update_permission( + req: CreateOrUpdateRoleRequest, + #_: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) +) -> RoleResponse: + doc = await role_service.create_or_update_role(req.role_key, req.role_name, req.role_level, req.custom_role_id, req.role_description) + + return RoleResponse(**doc.model_dump()) diff --git a/webapi/routes/role/create_role.py b/webapi/routes/role/create_role.py new file mode 100644 index 0000000..9cb2646 --- /dev/null +++ b/webapi/routes/role/create_role.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional, List + +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + + +class CreateRoleRequest(BaseModel): + role_key: str + role_name: str + role_description: Optional[str] = None + role_level: int + + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + + +@router.post( + "/create", + response_model=RoleResponse, + operation_id="create-role", + summary="Create Role", + description="Create a new role." +) +async def create_role( + req: CreateRoleRequest, + # _: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) +) -> RoleResponse: + doc = await role_service.create_role(req.role_key, req.role_name, req.role_description, req.role_level) + return RoleResponse(**doc.model_dump()) diff --git a/webapi/routes/role/delete_role.py b/webapi/routes/role/delete_role.py new file mode 100644 index 0000000..7ee71df --- /dev/null +++ b/webapi/routes/role/delete_role.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +token_manager = TokenManager() +router = APIRouter() +role_service = RoleService() + + +class DeleteRoleRequest(BaseModel): + role_id: str + + +class DeleteRoleResponse(BaseModel): + success: bool + + +@router.post( + "/delete", + response_model=DeleteRoleResponse, + operation_id="delete-role", + summary="Delete Role", + description="Delete a role after checking if it is referenced by any user." +) +async def delete_role( + req: DeleteRoleRequest, + #_: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) +) -> DeleteRoleResponse: + await role_service.delete_role(req.role_id) + return DeleteRoleResponse(success=True) diff --git a/webapi/routes/role/query_role.py b/webapi/routes/role/query_role.py new file mode 100644 index 0000000..8abc853 --- /dev/null +++ b/webapi/routes/role/query_role.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + +class QueryRoleRequest(BaseModel): + role_key: Optional[str] = None + role_name: Optional[str] = None + page: int = 1 + page_size: int = 10 + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + +class QueryRoleResponse(BaseModel): + items: List[RoleResponse] + total: int + page: int + page_size: int + +@router.post( + "/query", + response_model=QueryRoleResponse, + operation_id="query-role", + summary="Query Roles (paginated)", + description="Query roles with pagination and fuzzy search. Only Admin role allowed." +) +async def query_roles( + req: QueryRoleRequest, +) -> QueryRoleResponse: + result = await role_service.query_roles(req.role_key, req.role_name, req.page, req.page_size) + items = [RoleResponse(**item) for item in result["items"]] + return QueryRoleResponse( + items=items, + total=result["total"], + page=result["page"], + page_size=result["page_size"] + ) \ No newline at end of file diff --git a/webapi/routes/role/query_role_no_pagination.py b/webapi/routes/role/query_role_no_pagination.py new file mode 100644 index 0000000..3721d3b --- /dev/null +++ b/webapi/routes/role/query_role_no_pagination.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + +class QueryRoleNoPaginationRequest(BaseModel): + role_id: Optional[str] = None + role_key: Optional[str] = None + role_name: Optional[str] = None + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + +class QueryRoleNoPaginationResponse(BaseModel): + items: List[RoleResponse] + total: int + +@router.post( + "/query_role_no_pagination", + response_model=QueryRoleNoPaginationResponse, + operation_id="query-role-no-pagination", + summary="Query Role No Pagination", + description="Query roles fuzzy search without pagination." +) +async def query_role_no_pagination( + req: QueryRoleNoPaginationRequest, +) -> QueryRoleNoPaginationResponse: + result = await role_service.query_roles_no_pagination(req.role_id, req.role_key, req.role_name) + items = [RoleResponse(**item) for item in result["items"]] + return QueryRoleNoPaginationResponse( + items=items, + total=result["total"] + ) diff --git a/webapi/routes/role/update_role.py b/webapi/routes/role/update_role.py new file mode 100644 index 0000000..7a930ba --- /dev/null +++ b/webapi/routes/role/update_role.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional, List + +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + + +class UpdateRoleRequest(BaseModel): + role_id: str + role_key: str + role_name: str + role_description: Optional[str] = None + role_level: int + + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + + +@router.post( + "/update", + response_model=RoleResponse, + operation_id="update-role", + summary="Update Role", + description="Update an existing role by id. Only Admin role allowed." +) +async def update_role( + req: UpdateRoleRequest, + #_: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) +) -> RoleResponse: + doc = await role_service.update_role(req.role_id, req.role_key, req.role_name, req.role_description, req.role_level) + return RoleResponse(**doc.model_dump()) diff --git a/webapi/routes/signin/__init__.py b/webapi/routes/signin/__init__.py new file mode 100644 index 0000000..1e6aab1 --- /dev/null +++ b/webapi/routes/signin/__init__.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter +from .try_signin_with_email import router as ts_router +from .try_magicleaps_signin_with_email import router as tms_router +from .signin_with_email_and_password import router as se_router +from .signin_with_email_and_code import router as sw_router +from .signin_with_magicleaps_email_and_code import router as swm_router +from .update_user_password import router as up_router +from .update_user_password_no_depot import router as upnd_router +from .update_new_user_flid import router as uu_router +from .reset_password_through_email import router as rp_router +from .sign_out import router as so_router + + +router = APIRouter() + +router.include_router(ts_router, prefix="/signin", tags=["signin"]) +router.include_router(tms_router, prefix="/signin", tags=["signin"]) +router.include_router(sw_router, prefix="/signin", tags=["signin"]) +router.include_router(swm_router, prefix="/signin", tags=["signin"]) +router.include_router(up_router, prefix="/signin", tags=["signin"]) +router.include_router(upnd_router, prefix="/signin", tags=["signin"]) +router.include_router(se_router, prefix="/signin", tags=["signin"]) +router.include_router(so_router, prefix="/signin", tags=["signin"]) +router.include_router(rp_router, prefix="/signin", tags=["signin"]) +router.include_router(uu_router, prefix="/signin", tags=["signin"]) diff --git a/webapi/routes/signin/reset_password_through_email.py b/webapi/routes/signin/reset_password_through_email.py new file mode 100644 index 0000000..e4f1519 --- /dev/null +++ b/webapi/routes/signin/reset_password_through_email.py @@ -0,0 +1,37 @@ +from backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +router = APIRouter() + +# Web API +# reset_password_through_email +# + + +class UserSignWithEmailBody(BaseModel): + email: str + host: str + + +class UserSignWithEmailResponse(BaseModel): + signin_type: int + + +@router.post( + "/reset-password-through-email", + operation_id="user-reset-password-through-email", + summary="user reset password through email", + description="A client user forgets the password. \ + The system will send auth code the their email\ + to let the user reset the password", + response_description="action: UserLoginAction", +) +async def reset_password_through_email( + item: UserSignWithEmailBody, +): + result = await SignInHub().reset_password_through_email(item.email, item.host) + result = {"action": result} + return JSONResponse(content=jsonable_encoder(result)) diff --git a/webapi/routes/signin/sign_out.py b/webapi/routes/signin/sign_out.py new file mode 100644 index 0000000..040821f --- /dev/null +++ b/webapi/routes/signin/sign_out.py @@ -0,0 +1,40 @@ +from backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from fastapi import APIRouter +from common.token.token_manager import TokenManager, CurrentUser +from fastapi import APIRouter, Depends +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from fastapi import Depends, HTTPException +from starlette.status import HTTP_401_UNAUTHORIZED + +router = APIRouter() +token_manager = TokenManager() +# Web API +# sign_out +# + + +class RequestIn(BaseModel): + identity: str + + +@router.post( + "/sign-out", + operation_id="sign-out", + summary="sign out a logged in user", + description="sign out a logged in user", + response_description="none", +) +async def sign_out( + item: RequestIn, + current_user: CurrentUser = Depends(token_manager.get_current_user), +): + user_id = current_user.user_id + + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) + result = await SignInHub().sign_out(user_id) + return JSONResponse(content=jsonable_encoder(result)) diff --git a/webapi/routes/signin/signin_with_email_and_code.py b/webapi/routes/signin/signin_with_email_and_code.py new file mode 100644 index 0000000..5410356 --- /dev/null +++ b/webapi/routes/signin/signin_with_email_and_code.py @@ -0,0 +1,97 @@ +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional + +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from backend.application.signin_hub import SignInHub +from common.constants.jwt_constants import USER_ROLE_NAMES, USER_PERMISSIONS +from common.token.token_manager import TokenManager + + +router = APIRouter() +token_manager = TokenManager() + +# Web API +# signin-with-email-n-code +# + + +class RequestIn(BaseModel): + email: str + code: str + host: str + time_zone: Optional[str] = "UTC" + + +class ResponseOut(BaseModel): + # 1: succeeded + signin_result: int + # the access token for futhur communication with server + access_token: Optional[str] = None + # the refresh token for new access token generation + refresh_token: Optional[str] = None + # the identity of the signed in user + identity: Optional[str] = None + # the date time when the access toke will be expired + expires_in: Optional[datetime] = None + # the system assigned role of the user. + role: Optional[int] = None + # preferred region for user + preferred_region: Optional[str] = None + + +@router.post( + "/signin-with-email-and-code", + operation_id="user-signin-with-email-and-code", + summary="try to signin with email and authentication code", + description="client user is trying to sign in with their email and the authenication code \ + the system sent to the email in previous step.", + response_model=ResponseOut, +) +async def signin_with_email_and_code(item: RequestIn) -> ResponseOut: + ( + signed_in, + adminstrative_role, + identity, + flid, + preferred_region, + user_role_names, + user_permission_keys + ) = await SignInHub().signin_with_email_and_code( + item.email, item.code, item.host, item.time_zone + ) + + logging.debug( + f"signedin={signed_in}, adminstrative_role={adminstrative_role}, identity={identity}" + ) + + if signed_in and identity and adminstrative_role: + subject = {"id": identity, "role": adminstrative_role, USER_ROLE_NAMES: user_role_names, USER_PERMISSIONS: user_permission_keys} + access_token = token_manager.create_access_token(subject=subject) + refresh_token = token_manager.create_refresh_token(subject=subject) + expires_in = datetime.now(timezone.utc) + timedelta( + minutes=token_manager.access_token_expire_minutes + ) + else: + access_token = None + refresh_token = None + expires_in = None + + result = { + "signin_result": signed_in, + "access_token": access_token, + "refresh_token": refresh_token, + "identity": identity, + "expires_in": expires_in, + "role": adminstrative_role, + USER_ROLE_NAMES: user_role_names, + USER_PERMISSIONS: user_permission_keys, + "flid": flid, + "preferred_region": preferred_region, + } + + return JSONResponse(content=jsonable_encoder(result)) diff --git a/webapi/routes/signin/signin_with_email_and_password.py b/webapi/routes/signin/signin_with_email_and_password.py new file mode 100644 index 0000000..d1cdcf4 --- /dev/null +++ b/webapi/routes/signin/signin_with_email_and_password.py @@ -0,0 +1,92 @@ +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional + +from pydantic import BaseModel +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from fastapi import Depends, HTTPException +from starlette.status import HTTP_401_UNAUTHORIZED + +from backend.application.signin_hub import SignInHub +from common.constants.jwt_constants import USER_ROLE_NAMES, USER_PERMISSIONS +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() + +# Web API +# signin-with-email-n-password +# + + +class RequestIn(BaseModel): + email: str + password: str + + +class ResponseOut(BaseModel): + # 1: succeeded + signin_result: int + # the access token for futhur communication with server + access_token: Optional[str] = None + # the refresh token for new access token generation + refresh_token: Optional[str] = None + # the identity of the signed in user + identity: Optional[str] = None + # the date time when the access toke will be expired + expires_in: Optional[datetime] = None + # the system assigned role of the user. + role: Optional[int] = None + # the flid of the user + flid: Optional[str] = None + + +@router.post( + "/signin-with-email-and-password", + operation_id="user-signin-with-email-and-password", + summary="try to signin with email and password", + description="client user is trying to sign in with their email and the password .", + response_model=ResponseOut, +) +async def signin_with_email_and_password( + item: RequestIn, +) -> ResponseOut: + ( + signed_in, + adminstrative_role, + identity, + flid, + user_role_names, + user_permission_keys + ) = await SignInHub().signin_with_email_and_password(item.email, item.password) + + logging.debug( + f"signedin={signed_in}, adminstrative_role={adminstrative_role}, identity={identity}" + ) + + if signed_in and adminstrative_role and identity: + subject = {"id": identity, "role": adminstrative_role, USER_ROLE_NAMES: user_role_names, USER_PERMISSIONS: user_permission_keys} + access_token = token_manager.create_access_token(subject=subject) + refresh_token = token_manager.create_refresh_token(subject=subject) + expires_in = datetime.now(timezone.utc) + timedelta( + token_manager.access_token_expire_minutes + ) + else: + access_token = None + refresh_token = None + expires_in = None + + result = { + "signin_result": signed_in, + "access_token": access_token, + "refresh_token": refresh_token, + "identity": identity, + "expires_in": expires_in, + "role": adminstrative_role, + USER_ROLE_NAMES: user_role_names, + USER_PERMISSIONS: user_permission_keys, + "flid": flid, + } + return JSONResponse(content=jsonable_encoder(result)) diff --git a/webapi/routes/signin/signin_with_magicleaps_email_and_code.py b/webapi/routes/signin/signin_with_magicleaps_email_and_code.py new file mode 100644 index 0000000..ce0a435 --- /dev/null +++ b/webapi/routes/signin/signin_with_magicleaps_email_and_code.py @@ -0,0 +1,97 @@ +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional + +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from backend.application.signin_hub import SignInHub +from common.constants.jwt_constants import USER_ROLE_NAMES, USER_PERMISSIONS +from common.token.token_manager import TokenManager + + +router = APIRouter() +token_manager = TokenManager() + +# Web API +# signin_with_magicleaps_email_and_code +# + + +class RequestIn(BaseModel): + email: str + code: str + host: str + time_zone: Optional[str] = "UTC" + + +class ResponseOut(BaseModel): + # 1: succeeded + signin_result: int + # the access token for futhur communication with server + access_token: Optional[str] = None + # the refresh token for new access token generation + refresh_token: Optional[str] = None + # the identity of the signed in user + identity: Optional[str] = None + # the date time when the access toke will be expired + expires_in: Optional[datetime] = None + # the system assigned role of the user. + role: Optional[int] = None + # preferred region for user + preferred_region: Optional[str] = None + + +@router.post( + "/signin-with-magicleaps-email-and-code", + operation_id="user-signin-with-magicleaps-email-and-code", + summary="try to signin with email and authentication code using MagicLeaps branding", + description="client user is trying to sign in with their email and the authenication code \ + the system sent to the email in previous step using MagicLeaps branding.", + response_model=ResponseOut, +) +async def signin_with_magicleaps_email_and_code(item: RequestIn) -> ResponseOut: + ( + signed_in, + adminstrative_role, + identity, + flid, + preferred_region, + user_role_names, + user_permission_keys + ) = await SignInHub().signin_with_email_and_code( + item.email, item.code, item.host, item.time_zone + ) + + logging.debug( + f"signedin={signed_in}, adminstrative_role={adminstrative_role}, identity={identity}" + ) + + if signed_in and identity and adminstrative_role: + subject = {"id": identity, "role": adminstrative_role, USER_ROLE_NAMES: user_role_names, USER_PERMISSIONS: user_permission_keys} + access_token = token_manager.create_access_token(subject=subject) + refresh_token = token_manager.create_refresh_token(subject=subject) + expires_in = datetime.now(timezone.utc) + timedelta( + minutes=token_manager.access_token_expire_minutes + ) + else: + access_token = None + refresh_token = None + expires_in = None + + result = { + "signin_result": signed_in, + "access_token": access_token, + "refresh_token": refresh_token, + "identity": identity, + "expires_in": expires_in, + "role": adminstrative_role, + USER_ROLE_NAMES: user_role_names, + USER_PERMISSIONS: user_permission_keys, + "flid": flid, + "preferred_region": preferred_region, + } + + return JSONResponse(content=jsonable_encoder(result)) \ No newline at end of file diff --git a/webapi/routes/signin/try_magicleaps_signin_with_email.py b/webapi/routes/signin/try_magicleaps_signin_with_email.py new file mode 100644 index 0000000..50a2843 --- /dev/null +++ b/webapi/routes/signin/try_magicleaps_signin_with_email.py @@ -0,0 +1,37 @@ +from backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +router = APIRouter() + +# Web API +# try_magicleaps_signin_with_email +# + + +class UserSignWithEmailBody(BaseModel): + email: str + host: str + + +class UserSignWithEmailResponse(BaseModel): + signin_type: int + + +@router.post( + "/try-magicleaps-signin-with-email", + operation_id="user-try-magicleaps-signin-with-email", + summary="try to signin with email using MagicLeaps branding", + description="A client user is trying to sign in with their email using MagicLeaps branding. \ + The system will determine to send an authentication code to the email \ + or let the user use their FLID and password to sign in", + response_description="signin_type:0 meaning simplified(using email) signin, \ + 1 meaning standard(using FLID and password) signin", +) +async def try_magicleaps_signin_with_email( + item: UserSignWithEmailBody, +): + result = await SignInHub().try_magicleaps_signin_with_email(item.email, item.host) + return JSONResponse(content=jsonable_encoder(result)) \ No newline at end of file diff --git a/webapi/routes/signin/try_signin_with_email.py b/webapi/routes/signin/try_signin_with_email.py new file mode 100644 index 0000000..183db20 --- /dev/null +++ b/webapi/routes/signin/try_signin_with_email.py @@ -0,0 +1,37 @@ +from backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +router = APIRouter() + +# Web API +# try_signin_with_email +# + + +class UserSignWithEmailBody(BaseModel): + email: str + host: str + + +class UserSignWithEmailResponse(BaseModel): + signin_type: int + + +@router.post( + "/try-signin-with-email", + operation_id="user-try-signin-with-email", + summary="try to signin with email", + description="A client user is trying to sign in with their email. \ + The system will determine to send an authentication code to the email \ + or let the uesr use their FLID and passward to sign in", + response_description="signin_type:0 meaning simplified(using email) signin, \ + 1 meaning standard(using FLID and passward) signin", +) +async def try_signin_with_email( + item: UserSignWithEmailBody, +): + result = await SignInHub().try_signin_with_email(item.email, item.host) + return JSONResponse(result) diff --git a/webapi/routes/signin/update_new_user_flid.py b/webapi/routes/signin/update_new_user_flid.py new file mode 100644 index 0000000..4d43208 --- /dev/null +++ b/webapi/routes/signin/update_new_user_flid.py @@ -0,0 +1,54 @@ +from backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from common.token.token_manager import TokenManager +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from fastapi import APIRouter, HTTPException, Security +from starlette.status import HTTP_401_UNAUTHORIZED +from jose import jwt +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from common.config.app_settings import app_settings + +router = APIRouter() +token_manager = TokenManager() +# Web API +# update_user_flid +# + + +class RequestIn(BaseModel): + flid: str + + +@router.post( + "/update-new-user-flid", + operation_id="user_update_new_user_password", + summary="update the new user's freeleaps user id", + description="Freeleaps user ID(FLID) is a unique identifier to be used in git and other service across the platform", + response_description="signin result to indicate the next step for the client", +) +async def update_new_user_flid( + item: RequestIn, + credentials: HTTPAuthorizationCredentials = Security(HTTPBearer()), +): + payload = jwt.decode( + credentials.credentials, + app_settings.JWT_SECRET_KEY, + algorithms=[app_settings.JWT_ALGORITHM], + ) + user_id = payload.get("subject").get("id") + + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) + + ( + signed_in, + flid, + ) = await SignInHub().update_new_user_flid(user_id, item.flid) + result = { + "signin_result": signed_in, + "flid": flid, + } + return JSONResponse(content=jsonable_encoder(result)) diff --git a/webapi/routes/signin/update_user_password.py b/webapi/routes/signin/update_user_password.py new file mode 100644 index 0000000..671cdb5 --- /dev/null +++ b/webapi/routes/signin/update_user_password.py @@ -0,0 +1,55 @@ +from backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from fastapi import APIRouter, Security, HTTPException +from common.token.token_manager import TokenManager +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from starlette.status import HTTP_401_UNAUTHORIZED +from jose import jwt +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from common.config.app_settings import app_settings + +router = APIRouter() +token_manager = TokenManager() +# Web API +# update_user_password +# + + +class RequestIn(BaseModel): + password: str + password2: str + + +@router.post( + "/update-user-password", + operation_id="user_update_user_password", + summary="updathe user's sign-in password", + description="Update the user's sign-in password. If the password was not set yet, this will enable the user to log in using the password", + response_description="signin_type:0 meaning simplified(using email) signin, \ + 1 meaning standard(using FLID and passward) signin", +) +async def update_user_password( + item: RequestIn, + credentials: HTTPAuthorizationCredentials = Security(HTTPBearer()), +): + payload = jwt.decode( + credentials.credentials, + app_settings.JWT_SECRET_KEY, + algorithms=[app_settings.JWT_ALGORITHM], + ) + + user_id = payload.get("subject").get("id") + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) + if item.password != item.password2: + return JSONResponse( + content=jsonable_encoder( + {"error": "password and password2 are not the same"} + ) + ) + else: + result = await SignInHub().update_user_password(user_id, item.password) + return JSONResponse(content=jsonable_encoder(result)) diff --git a/webapi/routes/signin/update_user_password_no_depot.py b/webapi/routes/signin/update_user_password_no_depot.py new file mode 100644 index 0000000..7781984 --- /dev/null +++ b/webapi/routes/signin/update_user_password_no_depot.py @@ -0,0 +1,55 @@ +from backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from fastapi import APIRouter, Security, HTTPException +from common.token.token_manager import TokenManager +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from starlette.status import HTTP_401_UNAUTHORIZED +from jose import jwt +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from common.config.app_settings import app_settings + +router = APIRouter() +token_manager = TokenManager() +# Web API +# update_user_password_no_depot +# + + +class RequestIn(BaseModel): + password: str + password2: str + + +@router.post( + "/update-user-password-no-depot", + operation_id="user_update_user_password_no_depot", + summary="update user's sign-in password without depot service", + description="Update the user's sign-in password without updating code depot. If the password was not set yet, this will enable the user to log in using the password", + response_description="signin_type:0 meaning simplified(using email) signin, \ + 1 meaning standard(using FLID and passward) signin", +) +async def update_user_password_no_depot( + item: RequestIn, + credentials: HTTPAuthorizationCredentials = Security(HTTPBearer()), +): + payload = jwt.decode( + credentials.credentials, + app_settings.JWT_SECRET_KEY, + algorithms=[app_settings.JWT_ALGORITHM], + ) + + user_id = payload.get("subject").get("id") + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) + if item.password != item.password2: + return JSONResponse( + content=jsonable_encoder( + {"error": "password and password2 are not the same"} + ) + ) + else: + result = await SignInHub().update_user_password_no_depot(user_id, item.password) + return JSONResponse(content=jsonable_encoder(result)) diff --git a/webapi/routes/tokens/__init__.py b/webapi/routes/tokens/__init__.py new file mode 100644 index 0000000..cdee40c --- /dev/null +++ b/webapi/routes/tokens/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter +from .generate_tokens import router as generate_tokens_router +from .refresh_token import router as refresh_token_router +from .verify_token import router as verify_token_router + +router = APIRouter() + +router.include_router( + generate_tokens_router, prefix="/token", tags=["token"] +) +router.include_router(refresh_token_router, prefix="/token", tags=["token"]) +router.include_router(verify_token_router, prefix="/token", tags=["token"]) diff --git a/webapi/routes/tokens/generate_tokens.py b/webapi/routes/tokens/generate_tokens.py new file mode 100644 index 0000000..29d6389 --- /dev/null +++ b/webapi/routes/tokens/generate_tokens.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from datetime import datetime, timedelta, timezone +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() # Initialize TokenManager + + +class TokenRequest(BaseModel): + id: str + role: int + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + expires_in: datetime + + +@router.post("/generate-tokens", response_model=TokenResponse) +async def generate_tokens(request: TokenRequest): + """ + Endpoint to generate access and refresh tokens. + """ + subject = {"id": request.id, "role": request.role} + access_token = token_manager.create_access_token(subject) + refresh_token = token_manager.create_refresh_token(subject) + expires_in = datetime.now(timezone.utc) + timedelta( + minutes=token_manager.access_token_expire_minutes + ) + + return TokenResponse( + access_token=access_token, refresh_token=refresh_token, expires_in=expires_in + ) diff --git a/webapi/routes/tokens/refresh_token.py b/webapi/routes/tokens/refresh_token.py new file mode 100644 index 0000000..38950a8 --- /dev/null +++ b/webapi/routes/tokens/refresh_token.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() # Initialize TokenManager + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + id: str + role: int + + +class RefreshTokenResponse(BaseModel): + access_token: str + refresh_token: str + + +@router.post("/refresh-token", response_model=RefreshTokenResponse) +async def refresh_token(request: RefreshTokenRequest): + """ + Endpoint to refresh the access token using a valid refresh token. + """ + subject = {"id": request.id, "role": request.role} + + try: + access_token = token_manager.refresh_access_token( + request.refresh_token, subject + ) + refresh_token = token_manager.create_refresh_token(subject) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return RefreshTokenResponse(access_token=access_token, refresh_token=refresh_token) diff --git a/webapi/routes/tokens/verify_token.py b/webapi/routes/tokens/verify_token.py new file mode 100644 index 0000000..bc18cca --- /dev/null +++ b/webapi/routes/tokens/verify_token.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() # Initialize TokenManager + + +class VerifyTokenRequest(BaseModel): + token: str + + +class VerifyTokenResponse(BaseModel): + valid: bool + payload: dict + + +@router.post("/verify-token", response_model=VerifyTokenResponse) +async def verify_token(request: VerifyTokenRequest): + """ + Endpoint to verify if a token is valid and return the payload. + """ + try: + payload = token_manager.decode_token(request.token) + return VerifyTokenResponse(valid=True, payload=payload) + except ValueError: + raise HTTPException(status_code=401, detail="Invalid or expired token") diff --git a/webapi/routes/user/__init__.py b/webapi/routes/user/__init__.py new file mode 100644 index 0000000..a114692 --- /dev/null +++ b/webapi/routes/user/__init__.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter +from .assign_roles import router as assign_role_router + +router = APIRouter() + +router.include_router(assign_role_router, prefix="/user", tags=["user"]) diff --git a/webapi/routes/user/assign_roles.py b/webapi/routes/user/assign_roles.py new file mode 100644 index 0000000..d4bb5eb --- /dev/null +++ b/webapi/routes/user/assign_roles.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter +from fastapi.params import Depends +from pydantic import BaseModel +from typing import List, Optional + +from backend.services.user.user_management_service import UserManagementService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +user_management_service = UserManagementService() + + +class AssignRolesRequest(BaseModel): + user_id: str + role_ids: List[str] + + +class UserRoleResponse(BaseModel): + user_id: str + role_ids: Optional[List[str]] + + +@router.post( + "/assign-roles", + response_model=UserRoleResponse, + operation_id="assign-roles-to-user", + summary="Assign Roles to User", + description="Assign roles to a user by updating or creating the UserRoleDoc." +) +async def assign_roles_to_user( + req: AssignRolesRequest, + #_: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.INVITE_COLLABORATOR.value.permission_key])), +) -> UserRoleResponse: + doc = await user_management_service.assign_roles_to_user(req.user_id, req.role_ids) + return UserRoleResponse(**doc.model_dump())