diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml new file mode 100644 index 0000000000..a64a0f34aa --- /dev/null +++ b/.github/codeql-config.yml @@ -0,0 +1,10 @@ +name: Loculus CodeQL config + +query-filters: + # Loculus is a stateless API: every request authenticates from scratch with + # either a bearer JWT or X-Service-Token header, no session cookies. Spring's + # CSRF protection is intentionally disabled in + # backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt and + # the corresponding alert is not actionable. + - exclude: + id: java/spring-disabled-csrf-protection diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fb7ce63576..895b9d5a8f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -46,20 +46,11 @@ updates: patch: update-types: - "patch" - - package-ecosystem: npm - directory: keycloak/keycloakify - schedule: - interval: monthly - groups: - minorAndPatch: - update-types: - - "minor" - - "patch" - package-ecosystem: docker directories: - website - backend - - keycloak/keycloakify + - registration-service - preprocessing/nextclade - preprocessing/dummy - ingest diff --git a/.github/workflows/build-arm-images.yaml b/.github/workflows/build-arm-images.yaml index 7bf61f20c4..df32735974 100644 --- a/.github/workflows/build-arm-images.yaml +++ b/.github/workflows/build-arm-images.yaml @@ -63,10 +63,10 @@ jobs: uses: ./.github/workflows/ena-submission-flyway-image.yaml with: build_arm: true - trigger-keycloakify: + trigger-registration-service: needs: should-build if: needs.should-build.outputs.should_run == 'true' - uses: ./.github/workflows/keycloakify-image.yml + uses: ./.github/workflows/registration-service-image.yml with: build_arm: true trigger-preprocessing-nextclade: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a004ce2d01..97cebb35f9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,6 +41,7 @@ jobs: with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} + config-file: .github/codeql-config.yml - if: matrix.language == 'java-kotlin' && matrix.build-mode == 'manual' name: Set up JDK uses: actions/setup-java@v5 diff --git a/.github/workflows/keycloakify-test.yml b/.github/workflows/keycloakify-test.yml deleted file mode 100644 index ef96c762f3..0000000000 --- a/.github/workflows/keycloakify-test.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Testing keycloakify local development builds, e.g. for approving dependabot upgrades -name: keycloakify-test -on: - workflow_dispatch: - pull_request: - paths: - - "keycloak/keycloakify/**" - - ".github/workflows/keycloakify-test.yml" - push: - branches: - - main -concurrency: - group: ci-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }}-keycloak-test - cancel-in-progress: true -jobs: - keycloakify-test: - name: Test keycloakify local builds - runs-on: ubuntu-latest - timeout-minutes: 30 - defaults: - run: - working-directory: keycloak/keycloakify - steps: - - uses: actions/checkout@v6 - - name: Checkout Repo - uses: actions/checkout@v6 - - name: Setup Node.js environment - uses: actions/setup-node@v6 - with: - node-version-file: keycloak/keycloakify/.nvmrc - - run: | - corepack enable && - corepack install # use the in-repo yarn version - - name: Setup Yarn in Node - uses: actions/setup-node@v6 - with: - node-version-file: keycloak/keycloakify/.nvmrc - cache-dependency-path: keycloak/keycloakify/yarn.lock - cache: "yarn" - - name: Install dependencies - run: yarn install --immutable - - name: Build - run: yarn build - - name: Build keycloak theme - run: yarn build-keycloak-theme diff --git a/.github/workflows/keycloakify-image.yml b/.github/workflows/registration-service-image.yml similarity index 73% rename from .github/workflows/keycloakify-image.yml rename to .github/workflows/registration-service-image.yml index 7ece13065a..3205f65de9 100644 --- a/.github/workflows/keycloakify-image.yml +++ b/.github/workflows/registration-service-image.yml @@ -1,4 +1,4 @@ -name: keycloakify-image +name: registration-service-image on: pull_request: push: @@ -19,36 +19,29 @@ on: default: false required: false env: - DOCKER_IMAGE_NAME: ghcr.io/loculus-project/keycloakify + DOCKER_IMAGE_NAME: ghcr.io/loculus-project/registration-service BRANCH_NAME: ${{ github.head_ref || github.ref_name }} BUILD_ARM: ${{ github.event.inputs.build_arm || inputs.build_arm || github.ref == 'refs/heads/main' }} sha: ${{ github.event.pull_request.head.sha || github.sha }} concurrency: - group: ci-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }}-keycloak-buil-${{github.event.inputs.build_arm}}d + group: ci-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }}-registration-service-${{github.event.inputs.build_arm}} cancel-in-progress: true jobs: - keycloakify-image: - name: Build keycloakify Docker Image # Don't change: Referenced by .github/workflows/update-argocd-metadata.yml + registration-service-image: + name: Registration service docker image build # Don't change: Referenced by .github/workflows/update-argocd-metadata.yml runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 15 permissions: contents: read packages: write - checks: read steps: - name: Shorten sha run: echo "sha=${sha::7}" >> $GITHUB_ENV - uses: actions/checkout@v6 - - name: Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - name: Generate files hash id: files-hash run: | - DIR_HASH=$(echo -n ${{ hashFiles('./keycloak/keycloakify/**','.github/workflows/keycloakify-image.yml', './website/.nvmrc' ) }}) + DIR_HASH=$(echo -n ${{ hashFiles('registration-service/**', '.github/workflows/registration-service-image.yml') }}) echo "DIR_HASH=$DIR_HASH${{ env.BUILD_ARM == 'true' && '-arm' || '' }}" >> $GITHUB_ENV - name: Setup Docker metadata id: dockerMetadata @@ -61,6 +54,12 @@ jobs: type=raw,value=${{ env.BRANCH_NAME }} type=raw,value=commit-${{ env.sha }} type=raw,value=${{ env.BRANCH_NAME }}-arm,enable=${{ env.BUILD_ARM }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Check if image exists id: check-image run: | @@ -68,23 +67,16 @@ jobs: echo "CACHE_HIT=$EXISTS" >> $GITHUB_ENV - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Get node version build arg - id: get-node-version - if: env.CACHE_HIT == 'false' - run: | - NODE_VERSION=$(grep -v "^[[:space:]]*#" website/.nvmrc | tr -d 'v') - echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Build and push image if: env.CACHE_HIT == 'false' uses: docker/build-push-action@v7 with: - context: ./keycloak/keycloakify + context: ./registration-service push: true tags: ${{ steps.dockerMetadata.outputs.tags }} - cache-from: type=gha,scope=keycloakify-${{ github.ref }} - cache-to: type=gha,mode=max,scope=keycloakify-${{ github.ref }} + cache-from: type=gha,scope=registration-service-${{ github.ref }} + cache-to: type=gha,mode=max,scope=registration-service-${{ github.ref }} platforms: ${{ env.BUILD_ARM == 'true' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - build-args: NODE_VERSION=${{ steps.get-node-version.outputs.NODE_VERSION }} - name: Tag and push image if cache hit if: env.CACHE_HIT == 'true' run: | diff --git a/.github/workflows/update-argocd-metadata.yml b/.github/workflows/update-argocd-metadata.yml index a3d69fec23..83a5444ef4 100644 --- a/.github/workflows/update-argocd-metadata.yml +++ b/.github/workflows/update-argocd-metadata.yml @@ -71,11 +71,11 @@ jobs: check-name: Build ingest Docker Image repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 2 - - name: Wait for Keycloakify Docker Image + - name: Wait for Registration Service Docker Image uses: lewagon/wait-on-check-action@v1.7.0 with: ref: ${{ github.sha }} - check-name: Build keycloakify Docker Image + check-name: Registration service docker image build repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 2 - name: Wait for ENA Submission Docker Image diff --git a/architecture_docs/05_building_block_view.md b/architecture_docs/05_building_block_view.md index 82538a4bd3..0379d2bf74 100644 --- a/architecture_docs/05_building_block_view.md +++ b/architecture_docs/05_building_block_view.md @@ -16,13 +16,13 @@ and how they interact with each other and external participants. * use the website to browse the data and download sequences * or use LAPIS directly to query the data (e.g. for automated analysis). * Submitters can - * log in via Keycloak + * log in via Authelia * submit new sequence data via the website * or use the API directly to automate their submission process. * The backend infrastructure stores and processes the data. * LAPIS / SILO provides the query engine for the sequence data that is stored in the backend infrastructure. * The backend infrastructure also fetches sequence data from / uploads sequence data to INSDC services. -* The website and the backend infrastructure use Keycloak to verify the identity of users. +* The website and the backend infrastructure use Authelia to verify the identity of users. ## LAPIS / SILO diff --git a/architecture_docs/07_deployment_view.md b/architecture_docs/07_deployment_view.md index 9c9a9bcb79..0c297542be 100644 --- a/architecture_docs/07_deployment_view.md +++ b/architecture_docs/07_deployment_view.md @@ -30,9 +30,9 @@ We configured Traefik to expose the relevant services to the public: * the website, * the backend, * LAPIS, -* Keycloak. +* Authelia + lldap. -We only need a single instance of the website, the backend and keycloak (and their respective databases). +We only need a single instance of the website, the backend and authelia (and their respective databases). The other services (LAPIS, SILO, preprocessing pipeline, ingest and ENA deposition) have to be configured and deployed per organism that the Loculus instance supports. We utilize Helm to generate those multiple service instances. diff --git a/architecture_docs/09_architecture_decisions.md b/architecture_docs/09_architecture_decisions.md index cfc3c4e6a6..ecfa341812 100644 --- a/architecture_docs/09_architecture_decisions.md +++ b/architecture_docs/09_architecture_decisions.md @@ -77,3 +77,21 @@ Some relevant discussions: #### Decision We implemented the building blocks as described in this documentation. + +## Authentication: Authelia + lldap (2026) + +The original Keycloak deployment was replaced with Authelia for OIDC and lldap +as the user directory. Rationale: + +- Lighter footprint (Authelia and lldap together are smaller than Keycloak + alone) and a simpler operational model for self-hosted installations. +- LDAP backend is pluggable: bundled lldap for self-hosted, or operators can + point Authelia at an existing enterprise LDAP/AD by setting + `auth.bundledLdap.enabled=false` and configuring `auth.ldap.*`. +- Self-registration moves into a small dedicated `registration-service` that + writes new users into lldap via its GraphQL admin API; in BYO-LDAP mode this + service is not deployed and registration is managed out-of-band. +- ORCID social login is dropped; can be added later in the registration + service. +- CLI authentication moves from ROPC (unsupported in Authelia) to the OIDC + device-code flow. diff --git a/architecture_docs/plantuml/05_level_1.puml b/architecture_docs/plantuml/05_level_1.puml index 460c74427b..bfdcbec771 100644 --- a/architecture_docs/plantuml/05_level_1.puml +++ b/architecture_docs/plantuml/05_level_1.puml @@ -10,12 +10,12 @@ frame Loculus as loculus { component "Loculus Website" as website component "Loculus Backend Infrastructure" as backend component "LAPIS" as lapis - component "Keycloak" as keycloak + component "Authelia + lldap" as authelia } submitter --> website submitter -right-> backend -submitter --> keycloak +submitter --> authelia user --> website user --> lapis @@ -26,7 +26,7 @@ lapis --> backend backend --> insdc -backend -left-> keycloak -website -left-> keycloak +backend -left-> authelia +website -left-> authelia @enduml diff --git a/architecture_docs/plantuml/07_cluster_details.puml b/architecture_docs/plantuml/07_cluster_details.puml index fa35bbf223..d9f71719fe 100644 --- a/architecture_docs/plantuml/07_cluster_details.puml +++ b/architecture_docs/plantuml/07_cluster_details.puml @@ -8,7 +8,7 @@ node "Kubernetes Cluster" as loculus { component "Loculus Website" as website component "Loculus Backend" as backend - component "Keycloak" as keycloak + component "Authelia + lldap" as authelia node "One instance per organism" { component "Processing Pipeline" as processing @@ -21,17 +21,17 @@ node "Kubernetes Cluster" as loculus { } database "Loculus Database" as db -database "Keycloak Database" as kc_db +database "Authelia + lldap Database" as ldap_data " " --> traefik : HTTP traefik --> website traefik --> backend traefik --> lapis -traefik --> keycloak +traefik --> authelia backend --> db deposition --> db -keycloak --> kc_db +authelia --> ldap_data @enduml diff --git a/architecture_docs/plantuml/07_deployment_overview.puml b/architecture_docs/plantuml/07_deployment_overview.puml index 24bab36b67..f32df5f24a 100644 --- a/architecture_docs/plantuml/07_deployment_overview.puml +++ b/architecture_docs/plantuml/07_deployment_overview.puml @@ -7,9 +7,9 @@ node "Kubernetes Cluster" as loculus { } database "Loculus Database" as db -database "Keycloak Database" as kc_db +database "Authelia + lldap Database" as ldap_data services --> db -services --> kc_db +services --> ldap_data @enduml diff --git a/backend/build.gradle b/backend/build.gradle index 6f9aa1e067..4b8af79545 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -55,7 +55,7 @@ dependencies { implementation "org.jetbrains.exposed:exposed-kotlin-datetime" implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat" implementation "org.hibernate.validator:hibernate-validator" - implementation "org.keycloak:keycloak-admin-client:26.0.9" + implementation "org.springframework.boot:spring-boot-starter-data-ldap" implementation("io.minio:minio:9.0.0") implementation("software.amazon.awssdk:s3:2.44.2") @@ -82,6 +82,7 @@ dependencies { testImplementation "org.testcontainers:testcontainers-minio:2.0.5" testImplementation "org.awaitility:awaitility:4.3.0" testImplementation "org.junit.platform:junit-platform-launcher" + testImplementation "org.apache.httpcomponents:httpclient:4.5.14" ktlint("com.pinterest.ktlint:ktlint-cli:1.8.0") { attributes { attribute(Bundling.BUNDLING_ATTRIBUTE, getObjects().named(Bundling, Bundling.EXTERNAL)) diff --git a/backend/gradle.lockfile b/backend/gradle.lockfile index 71aeef2b02..de7baabf25 100644 --- a/backend/gradle.lockfile +++ b/backend/gradle.lockfile @@ -11,10 +11,6 @@ com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.19.2=compileClasspath com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-base:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-json-provider:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-yaml-provider:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.module:jackson-module-kotlin:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.module:jackson-module-parameter-names:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson:jackson-bom:2.19.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -25,7 +21,6 @@ com.github.ben-manes.caffeine:caffeine:3.2.2=swiftExportClasspathResolvable com.github.docker-java:docker-java-api:3.7.1=testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport-zerodep:3.7.1=testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport:3.7.1=testCompileClasspath,testRuntimeClasspath -com.github.java-json-tools:json-patch:1.13=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.luben:zstd-jni:1.5.7-8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.40.0=swiftExportClasspathResolvable @@ -34,7 +29,6 @@ com.google.guava:failureaccess:1.0.3=compileClasspath,productionRuntimeClasspath com.google.guava:guava:33.5.0-jre=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.j2objc:j2objc-annotations:3.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.ibm.async:asyncutil:0.1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:9.37.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.ninja-squad:springmockk:5.0.1=testCompileClasspath,testRuntimeClasspath @@ -59,15 +53,11 @@ com.squareup.okhttp3:okhttp-jvm:5.2.1=compileClasspath,productionRuntimeClasspat com.squareup.okhttp3:okhttp:5.2.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.squareup.okio:okio-jvm:3.16.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.squareup.okio:okio:3.16.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.sun.istack:istack-commons-runtime:4.1.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.sun.istack:istack-commons-tools:4.1.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.sun.xml.bind.external:relaxng-datatype:4.0.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.sun.xml.bind.external:rngom:4.0.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:6.3.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-codec:commons-codec:1.18.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.20.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -commons-logging:commons-logging:1.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath dev.drewhamilton.poko:poko-annotations-jvm:0.20.1=ktlint,ktlintBaselineReporter,ktlintRuleset dev.drewhamilton.poko:poko-annotations:0.20.1=ktlint,ktlintBaselineReporter,ktlintRuleset io.github.detekt.sarif4k:sarif4k-jvm:0.5.0=ktlint,ktlintReporter @@ -111,9 +101,7 @@ io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,productionRuntim io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:2.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.mail:jakarta.mail-api:2.1.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.ws.rs:jakarta.ws.rs-api:3.1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath junit:junit:4.13.2=testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath @@ -124,11 +112,9 @@ net.minidev:json-smart:2.5.2=testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-compress:1.28.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-csv:1.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.17.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.httpcomponents:httpclient:4.5.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.httpcomponents:httpcore:4.4.16=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.james:apache-mime4j-core:0.8.13=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.james:apache-mime4j-dom:0.8.13=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.james:apache-mime4j-storage:0.8.13=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpclient:4.5.13=productionRuntimeClasspath,runtimeClasspath +org.apache.httpcomponents:httpclient:4.5.14=testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents:httpcore:4.4.16=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.24.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-to-slf4j:2.24.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-core:10.1.48=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -145,32 +131,13 @@ org.bouncycastle:bcutil-jdk18on:1.80=kotlinBouncyCastleConfiguration org.checkerframework:checker-qual:3.43.0=swiftExportClasspathResolvable org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset -org.eclipse.angus:angus-activation:2.0.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.angus:angus-mail:2.0.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.eclipse.microprofile.openapi:microprofile-openapi-api:4.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.flywaydb:flyway-core:11.7.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.flywaydb:flyway-database-postgresql:11.7.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.glassfish.jaxb:codemodel:4.0.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-jxc:4.0.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-xjc:4.0.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.glassfish.jaxb:xsom:4.0.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.hamcrest:hamcrest-core:3.0=testRuntimeClasspath org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.hibernate.validator:hibernate-validator:8.0.3.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.logging:commons-logging-jboss-logging:2.0.0.Final=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.resteasy:resteasy-client-api:6.2.15.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.resteasy:resteasy-client:6.2.15.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.resteasy:resteasy-core-spi:6.2.15.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.resteasy:resteasy-core:6.2.15.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.resteasy:resteasy-jackson2-provider:6.2.15.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.resteasy:resteasy-jaxb-provider:6.2.15.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.resteasy:resteasy-multipart-provider:6.2.15.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss:jandex:2.4.5.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.exposed:exposed-bom:0.61.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.exposed:exposed-core:0.61.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.exposed:exposed-dao:0.61.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -234,8 +201,6 @@ org.junit.platform:junit-platform-commons:1.12.2=testCompileClasspath,testRuntim org.junit.platform:junit-platform-engine:1.12.2=testCompileClasspath,testRuntimeClasspath org.junit.platform:junit-platform-launcher:1.12.2=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:5.12.2=testCompileClasspath,testRuntimeClasspath -org.keycloak:keycloak-admin-client:26.0.9=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.keycloak:keycloak-client-common-synced:26.0.9=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -254,6 +219,7 @@ org.springframework.boot:spring-boot-actuator-autoconfigure:3.5.7=compileClasspa org.springframework.boot:spring-boot-actuator:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-actuator:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-ldap:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-json:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -267,6 +233,9 @@ org.springframework.boot:spring-boot-starter:3.5.7=compileClasspath,productionRu org.springframework.boot:spring-boot-test-autoconfigure:3.5.7=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:3.5.7=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot:3.5.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:3.5.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-ldap:3.5.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.ldap:spring-ldap-core:3.3.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-config:6.5.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-core:6.5.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-crypto:6.5.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/backend/src/main/kotlin/org/loculus/backend/auth/AuthenticatedUser.kt b/backend/src/main/kotlin/org/loculus/backend/auth/AuthenticatedUser.kt index b3bb9ec2ad..bbeb952eec 100644 --- a/backend/src/main/kotlin/org/loculus/backend/auth/AuthenticatedUser.kt +++ b/backend/src/main/kotlin/org/loculus/backend/auth/AuthenticatedUser.kt @@ -22,15 +22,24 @@ object Roles { open class User -class AuthenticatedUser(private val source: JwtAuthenticationToken) : User() { - val username: String - get() = source.token.claims[StandardClaimNames.PREFERRED_USERNAME] as String +class AuthenticatedUser private constructor(val username: String, val authorities: Collection) : User() { + companion object { + fun fromJwt(jwt: JwtAuthenticationToken): AuthenticatedUser = AuthenticatedUser( + username = jwt.token.claims[StandardClaimNames.PREFERRED_USERNAME] as String, + authorities = jwt.authorities.map { it.authority }, + ) + + fun fromServiceToken(token: ServiceTokenAuthentication): AuthenticatedUser = AuthenticatedUser( + username = token.principal, + authorities = token.authorities.map { it.authority }, + ) + } val isSuperUser: Boolean - get() = source.authorities.any { it.authority == SUPER_USER } + get() = authorities.any { it == SUPER_USER } val isPreprocessingPipeline: Boolean - get() = source.authorities.any { it.authority == PREPROCESSING_PIPELINE } + get() = authorities.any { it == PREPROCESSING_PIPELINE } } class AnonymousUser : User() @@ -48,7 +57,10 @@ class UserConverter : HandlerMethodArgumentResolver { ): Any? { val authentication = SecurityContextHolder.getContext().authentication if (authentication is JwtAuthenticationToken) { - return AuthenticatedUser(authentication) + return AuthenticatedUser.fromJwt(authentication) + } + if (authentication is ServiceTokenAuthentication) { + return AuthenticatedUser.fromServiceToken(authentication) } if (authentication is AnonymousAuthenticationToken) { return AnonymousUser() diff --git a/backend/src/main/kotlin/org/loculus/backend/auth/ServiceTokenAuthenticationFilter.kt b/backend/src/main/kotlin/org/loculus/backend/auth/ServiceTokenAuthenticationFilter.kt new file mode 100644 index 0000000000..be1296e846 --- /dev/null +++ b/backend/src/main/kotlin/org/loculus/backend/auth/ServiceTokenAuthenticationFilter.kt @@ -0,0 +1,102 @@ +package org.loculus.backend.auth + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import mu.KotlinLogging +import org.loculus.backend.auth.Roles.EXTERNAL_METADATA_UPDATER +import org.loculus.backend.auth.Roles.PREPROCESSING_PIPELINE +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +private val log = KotlinLogging.logger {} + +/** + * Static service tokens for backend service accounts. + * + * Authelia's OIDC provider can't inject fixed `groups` claims into + * `client_credentials` tokens (its claims_policies are anchored to the + * authentication backend, which is empty in the service-to-service case), + * so backend services authenticate with a pre-shared `X-Service-Token` + * header instead of going through the IDP. + * + * Token values come from the existing `service-accounts` secret in the + * Helm chart; each field is the raw token string for one well-known + * automation account. + */ +@ConfigurationProperties(prefix = "loculus.service-tokens") +data class ServiceTokenProperties( + val preprocessingPipeline: String? = null, + val externalMetadataUpdater: String? = null, + val insdcIngestUser: String? = null, + val backend: String? = null, +) + +class ServiceTokenAuthentication(private val name: String, authorities: Collection) : + AbstractAuthenticationToken(authorities) { + init { + isAuthenticated = true + } + + override fun getCredentials(): Any = "" + override fun getPrincipal(): String = name + override fun getName(): String = name +} + +private data class ServiceAccount(val username: String, val roles: List) + +@Component +class ServiceTokenAuthenticationFilter(props: ServiceTokenProperties) : OncePerRequestFilter() { + + private val byToken: Map = buildMap { + listOfNotNull( + props.preprocessingPipeline?.takeIf { it.isNotBlank() }?.let { + it to ServiceAccount("preprocessing_pipeline", listOf(PREPROCESSING_PIPELINE, "user")) + }, + props.externalMetadataUpdater?.takeIf { it.isNotBlank() }?.let { + it to ServiceAccount( + "external_metadata_updater", + listOf(EXTERNAL_METADATA_UPDATER, "get_released_data", "user"), + ) + }, + props.insdcIngestUser?.takeIf { it.isNotBlank() }?.let { + it to ServiceAccount("insdc_ingest_user", listOf("user")) + }, + props.backend?.takeIf { it.isNotBlank() }?.let { + it to ServiceAccount("backend", listOf("user")) + }, + ).forEach { (token, account) -> put(token, account) } + } + + init { + if (byToken.isNotEmpty()) { + log.info { "Service-token authentication enabled for ${byToken.values.map { it.username }}" } + } else { + log.info { "No service tokens configured; service-to-service auth disabled" } + } + } + + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { + val token = request.getHeader(HEADER) + if (!token.isNullOrBlank()) { + val account = byToken[token] + if (account != null) { + SecurityContextHolder.getContext().authentication = ServiceTokenAuthentication( + account.username, + account.roles.map { SimpleGrantedAuthority(it) }.toSet(), + ) + } else { + log.debug { "Unrecognised value for $HEADER header" } + } + } + chain.doFilter(request, response) + } + + companion object { + const val HEADER = "X-Service-Token" + } +} diff --git a/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt b/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt index 8017e9e188..300a67ddf1 100644 --- a/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt +++ b/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt @@ -6,19 +6,27 @@ import mu.KotlinLogging import org.loculus.backend.auth.Roles.EXTERNAL_METADATA_UPDATER import org.loculus.backend.auth.Roles.PREPROCESSING_PIPELINE import org.loculus.backend.auth.Roles.SUPER_USER +import org.loculus.backend.auth.ServiceTokenAuthenticationFilter import org.springframework.beans.factory.InitializingBean import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.web.client.RestTemplateBuilder import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.convert.converter.Converter import org.springframework.http.HttpMethod +import org.springframework.http.client.ClientHttpRequestInterceptor import org.springframework.security.access.AccessDeniedException import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.AuthenticationException import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.oauth2.core.oidc.StandardClaimNames import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtValidators +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler @@ -27,8 +35,10 @@ import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.access.AccessDeniedHandler import org.springframework.security.web.access.AccessDeniedHandlerImpl import org.springframework.security.web.access.DelegatingAccessDeniedHandler +import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter import org.springframework.security.web.csrf.CsrfException import org.springframework.stereotype.Component +import java.net.URI private val log = KotlinLogging.logger { } @@ -78,7 +88,18 @@ class SecurityConfig { fun securityFilterChain( httpSecurity: HttpSecurity, keycloakAuthoritiesConverter: KeycloakAuthenticationConverter, + serviceTokenFilter: ServiceTokenAuthenticationFilter, ): SecurityFilterChain = httpSecurity + // The API authenticates every request from scratch via either bearer + // JWT or X-Service-Token; no session is established. Marking the + // session policy STATELESS makes Spring skip both session creation + // and CSRF enforcement, so service-token POSTs aren't rejected by the + // CsrfFilter before our auth filter runs. + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .csrf { + it.disable() + } // codeql[java/spring-disabled-csrf-protection] -- stateless API; tokens travel in headers, no session cookie. + .addFilterBefore(serviceTokenFilter, AbstractPreAuthenticatedProcessingFilter::class.java) .authorizeHttpRequests { auth -> auth.requestMatchers( "/", @@ -110,6 +131,49 @@ class SecurityConfig { .accessDeniedHandler(LoggingAccessDeniedHandler(defaultAccessDeniedHandler)) } .build() + + @Bean + @ConditionalOnMissingBean(JwtDecoder::class) + fun jwtDecoder( + @Value("\${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") jwkSetUri: String, + @Value("\${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") issuerUri: String, + restTemplateBuilder: RestTemplateBuilder, + ): JwtDecoder { + val decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri) + .restOperations(restTemplateBuilder.withForwardedIssuerHeaders(issuerUri).build()) + .build() + + if (issuerUri.isNotBlank()) { + decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri)) + } + + return decoder + } +} + +private fun RestTemplateBuilder.withForwardedIssuerHeaders(issuerUri: String): RestTemplateBuilder { + if (issuerUri.isBlank()) { + return this + } + + val issuer = URI.create(issuerUri) + val scheme = issuer.scheme ?: return this + val host = issuer.rawAuthority ?: issuer.host ?: return this + val port = when { + issuer.port > 0 -> issuer.port.toString() + scheme == "https" -> "443" + scheme == "http" -> "80" + else -> return this + } + + val forwardedHeadersInterceptor = ClientHttpRequestInterceptor { request, body, execution -> + request.headers.add("X-Forwarded-Proto", scheme) + request.headers.add("X-Forwarded-Host", host) + request.headers.add("X-Forwarded-Port", port) + execution.execute(request, body) + } + + return additionalInterceptors(forwardedHeadersInterceptor) } @Component @@ -130,28 +194,14 @@ class KeycloakAuthoritiesConverter : Converter } } -fun getRoles(jwt: Jwt): List { - val defaultRealmAccess = mapOf>() - val realmAccess = when (jwt.claims["realm_access"]) { - null -> defaultRealmAccess +fun getRoles(jwt: Jwt): List = when (val groups = jwt.claims["groups"]) { + null -> emptyList() - is Map<*, *> -> jwt.claims["realm_access"] as Map<*, *> + is List<*> -> groups.filterIsInstance() - else -> { - log.debug { "Ignoring value of realm_access in jwt because type was not Map<*,*>" } - defaultRealmAccess - } - } - - return when (realmAccess["roles"]) { - null -> emptyList() - - is List<*> -> (realmAccess["roles"] as List<*>).filterIsInstance() - - else -> { - log.debug { "Ignoring value of roles in jwt because type was not List<*>" } - emptyList() - } + else -> { + log.debug { "Ignoring value of `groups` in jwt because type was not List<*>" } + emptyList() } } diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/FilesController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/FilesController.kt index cc829b28d0..93420ae95b 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/FilesController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/FilesController.kt @@ -6,7 +6,6 @@ import io.swagger.v3.oas.annotations.headers.Header import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.security.SecurityRequirement import jakarta.servlet.http.HttpServletRequest -import org.apache.http.HttpStatus import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.FileIdAndEtags import org.loculus.backend.api.FileIdAndMultipartWriteUrl @@ -23,6 +22,7 @@ import org.loculus.backend.utils.Accession import org.loculus.backend.utils.generateFileId import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping @@ -98,7 +98,7 @@ class FilesController( HttpMethod.GET -> s3Service.createUrlToReadPrivateFile(fileId, fileName) else -> throw RuntimeException("Unexpected error: /files/get was called with HTTP method $method") } - return ResponseEntity.status(HttpStatus.SC_TEMPORARY_REDIRECT) + return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT) .location(URI.create(presignedUrl)) .build() } diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt index 7b6ebd35de..031c8c24dd 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt @@ -14,7 +14,7 @@ import org.loculus.backend.auth.AuthenticatedUser import org.loculus.backend.auth.HiddenParam import org.loculus.backend.config.BackendSpringProperty import org.loculus.backend.config.ENABLE_SEQSETS_TRUE_VALUE -import org.loculus.backend.service.KeycloakAdapter +import org.loculus.backend.service.UserDirectory import org.loculus.backend.service.seqsetcitations.SeqSetCitationsDatabaseService import org.loculus.backend.service.submission.SubmissionDatabaseService import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -37,7 +37,7 @@ import org.springframework.web.bind.annotation.RestController class SeqSetCitationsController( private val seqSetCitationsService: SeqSetCitationsDatabaseService, private val submissionDatabaseService: SubmissionDatabaseService, - private val keycloakAdapter: KeycloakAdapter, + private val userDirectory: UserDirectory, ) { @Operation(description = "Get a SeqSet") @GetMapping("/get-seqset") @@ -111,9 +111,9 @@ class SeqSetCitationsController( @Operation(description = "Get an author") @GetMapping("/get-author") fun getAuthor(@RequestParam username: String): AuthorProfile { - val keycloakUser = keycloakAdapter.getUsersWithName(username).firstOrNull() + val user = userDirectory.getUsersWithName(username).firstOrNull() ?: throw NotFoundException("Author profile $username does not exist") - return seqSetCitationsService.transformKeycloakUserToAuthorProfile(keycloakUser) + return seqSetCitationsService.transformUserToAuthorProfile(user) } } diff --git a/backend/src/main/kotlin/org/loculus/backend/service/KeycloakAdapter.kt b/backend/src/main/kotlin/org/loculus/backend/service/KeycloakAdapter.kt deleted file mode 100644 index 6273f9c3aa..0000000000 --- a/backend/src/main/kotlin/org/loculus/backend/service/KeycloakAdapter.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.loculus.backend.service - -import org.keycloak.admin.client.KeycloakBuilder -import org.keycloak.representations.idm.UserRepresentation -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.stereotype.Component - -@ConfigurationProperties(prefix = "keycloak") -data class KeycloakProperties( - val user: String, - val password: String, - val realm: String, - val client: String, - val url: String, -) - -@Component -class KeycloakAdapter(private val keycloakProperties: KeycloakProperties) { - - private val keycloakRealm = KeycloakBuilder.builder() - .serverUrl(keycloakProperties.url) - .realm(keycloakProperties.realm) - .clientId(keycloakProperties.client) - .username(keycloakProperties.user) - .password(keycloakProperties.password) - .build() - .realm(keycloakProperties.realm) - - fun getUsersWithName(username: String): List = keycloakRealm - .users() - .search(username, true)!! -} diff --git a/backend/src/main/kotlin/org/loculus/backend/service/UserDirectory.kt b/backend/src/main/kotlin/org/loculus/backend/service/UserDirectory.kt new file mode 100644 index 0000000000..cfd9bd8c83 --- /dev/null +++ b/backend/src/main/kotlin/org/loculus/backend/service/UserDirectory.kt @@ -0,0 +1,80 @@ +package org.loculus.backend.service + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.ldap.core.AttributesMapper +import org.springframework.ldap.core.LdapTemplate +import org.springframework.ldap.core.support.LdapContextSource +import org.springframework.ldap.query.LdapQueryBuilder.query +import org.springframework.stereotype.Component +import javax.naming.directory.Attributes + +@ConfigurationProperties(prefix = "loculus.ldap") +data class LdapProperties( + val host: String, + val port: Int = 3890, + val baseDn: String, + val userBaseDn: String, + val groupBaseDn: String, + val userFilter: String, + val bindDn: String, + val bindPassword: String, +) + +/** + * A user as Loculus needs to know about them — username, email, name, and the + * "university / organisation" field surfaced by the user profile. + */ +data class LoculusUser( + val username: String, + val email: String?, + val firstName: String?, + val lastName: String?, + val organization: String?, +) + +@Component +class UserDirectory(private val props: LdapProperties) { + private val userSearchBase = props.userBaseDn.relativeToBaseDn() + + private val ldapTemplate: LdapTemplate = LdapTemplate( + LdapContextSource().apply { + setUrl("ldap://${props.host}:${props.port}") + userDn = props.bindDn + password = props.bindPassword + setBase(props.baseDn) + afterPropertiesSet() + }, + ) + + /** + * Look up a single user by their LDAP `uid`. Returns the matching user(s) + * — typically zero or one entry. + */ + fun getUsersWithName(username: String): List = ldapTemplate.search( + query().base(userSearchBase).where("uid").`is`(username), + UserAttributesMapper, + ) + + private fun String.relativeToBaseDn(): String { + if (equals(props.baseDn, ignoreCase = true)) { + return "" + } + val baseSuffix = ",${props.baseDn}" + return if (endsWith(baseSuffix, ignoreCase = true)) { + dropLast(baseSuffix.length) + } else { + this + } + } + + private object UserAttributesMapper : AttributesMapper { + override fun mapFromAttributes(attrs: Attributes): LoculusUser = LoculusUser( + username = attrs.get("uid")?.get()?.toString() ?: "", + email = attrs.get("mail")?.get()?.toString(), + firstName = attrs.get("givenName")?.get()?.toString(), + lastName = attrs.get("sn")?.get()?.toString(), + organization = attrs.get("o")?.get()?.toString() + ?: attrs.get("organizationName")?.get()?.toString(), + ) + } +} diff --git a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementPreconditionValidator.kt b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementPreconditionValidator.kt index 8f5961883d..c453e514da 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementPreconditionValidator.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementPreconditionValidator.kt @@ -5,12 +5,12 @@ import org.jetbrains.exposed.sql.selectAll import org.loculus.backend.auth.AuthenticatedUser import org.loculus.backend.controller.ForbiddenException import org.loculus.backend.controller.NotFoundException -import org.loculus.backend.service.KeycloakAdapter +import org.loculus.backend.service.UserDirectory import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional @Component -class GroupManagementPreconditionValidator(private val keycloakAdapter: KeycloakAdapter) { +class GroupManagementPreconditionValidator(private val userDirectory: UserDirectory) { @Transactional(readOnly = true) fun validateGroupExists(groupId: Int) { validateGroupsExist(listOf(groupId)) @@ -69,7 +69,7 @@ class GroupManagementPreconditionValidator(private val keycloakAdapter: Keycloak } fun validateThatUserExists(username: String) { - val users = keycloakAdapter.getUsersWithName(username) + val users = userDirectory.getUsersWithName(username) when { users.isEmpty() -> throw NotFoundException("User $username does not exist.") users.size > 1 -> throw IllegalStateException("Multiple users with name $username exist.") diff --git a/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt index bb52cd22b1..5f31e81a31 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt @@ -16,7 +16,6 @@ import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.max import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.update -import org.keycloak.representations.idm.UserRepresentation import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.AuthorProfile import org.loculus.backend.api.CitedBy @@ -30,6 +29,7 @@ import org.loculus.backend.auth.AuthenticatedUser import org.loculus.backend.config.BackendConfig import org.loculus.backend.controller.NotFoundException import org.loculus.backend.controller.UnprocessableEntityException +import org.loculus.backend.service.LoculusUser import org.loculus.backend.service.crossref.CrossRefService import org.loculus.backend.service.crossref.DoiEntry import org.loculus.backend.service.submission.AccessionPreconditionValidator @@ -535,14 +535,14 @@ class SeqSetCitationsDatabaseService( } } - fun transformKeycloakUserToAuthorProfile(keycloakUser: UserRepresentation): AuthorProfile { - val emailDomain = keycloakUser.email?.substringAfterLast("@") ?: "" + fun transformUserToAuthorProfile(user: LoculusUser): AuthorProfile { + val emailDomain = user.email?.substringAfterLast("@") ?: "" return AuthorProfile( - keycloakUser.username, - keycloakUser.firstName, - keycloakUser.lastName, - emailDomain, - keycloakUser.attributes["university"]?.firstOrNull(), + username = user.username, + firstName = user.firstName.orEmpty(), + lastName = user.lastName.orEmpty(), + emailDomain = emailDomain, + university = user.organization, ) } } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/RequestAuthorization.kt b/backend/src/test/kotlin/org/loculus/backend/controller/RequestAuthorization.kt index 0d6d28429f..b826435349 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/RequestAuthorization.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/RequestAuthorization.kt @@ -24,7 +24,7 @@ fun generateJwtFor(username: String, roles: List = emptyList()): String .issuedAt(Date.from(Instant.now())) .signWith(keyPair.private, Jwts.SIG.RS256) .claim("preferred_username", username) - .claim("realm_access", mapOf("roles" to roles)) + .claim("groups", roles) .compact() fun MockHttpServletRequestBuilder.withAuth(bearerToken: String? = jwtForDefaultUser): MockHttpServletRequestBuilder = diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/TestHelpers.kt b/backend/src/test/kotlin/org/loculus/backend/controller/TestHelpers.kt index d69ccc300f..0b0b5de5da 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/TestHelpers.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/TestHelpers.kt @@ -123,18 +123,14 @@ fun SequenceEntryStatus.assertIsRevocationIs(revoked: Boolean): SequenceEntrySta } fun expectUnauthorizedResponse(isModifyingRequest: Boolean = false, apiCall: (jwt: String?) -> ResultActions) { - val response = apiCall(null) - - // Spring handles non-modifying requests differently than modifying requests - // See https://github.com/spring-projects/spring-security/blob/c2d88eca5ac2b1638e28041e4ee8aaecf6b5ac6a/web/src/main/java/org/springframework/security/web/csrf/CsrfFilter.java#L205 - when (isModifyingRequest) { - true -> response.andExpect(status().isForbidden) - - false -> - response - .andExpect(status().isUnauthorized) - .andExpect(MockMvcResultMatchers.header().string("WWW-Authenticate", Matchers.containsString("Bearer"))) - } + @Suppress("UNUSED_PARAMETER") + val unused = isModifyingRequest + // CSRF protection is disabled (stateless API), so modifying and non-modifying + // requests both return 401 with a Bearer challenge when authentication is + // missing or invalid — the same status the OAuth2 resource server emits. + apiCall(null) + .andExpect(status().isUnauthorized) + .andExpect(MockMvcResultMatchers.header().string("WWW-Authenticate", Matchers.containsString("Bearer"))) apiCall("invalidToken") .andExpect(status().isUnauthorized) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/files/RequestUploadEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/files/RequestUploadEndpointTest.kt index ff795c4c76..f47c283833 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/files/RequestUploadEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/files/RequestUploadEndpointTest.kt @@ -141,11 +141,11 @@ class RequestUploadEndpointTest( // This should actually be 401 but current behavior is buggy // see https://github.com/loculus-project/loculus/issues/4601 @Test - fun `GIVEN request without authentication THEN returns 403 forbidden`() { + fun `GIVEN request without authentication THEN returns 401 unauthorized`() { val groupId = groupManagementClient.createNewGroup().andGetGroupId() client.requestUploads(groupId = groupId, numberFiles = 1, jwt = "") - .andExpect(status().isForbidden) + .andExpect(status().isUnauthorized) } @Test diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt index 9765c7221a..f4033308a3 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt @@ -10,7 +10,6 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource -import org.keycloak.representations.idm.UserRepresentation import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_GROUP import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_GROUP_NAME import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_USER_NAME @@ -24,7 +23,8 @@ import org.loculus.backend.controller.generateJwtFor import org.loculus.backend.controller.jwtForAlternativeUser import org.loculus.backend.controller.jwtForDefaultUser import org.loculus.backend.controller.jwtForSuperUser -import org.loculus.backend.service.KeycloakAdapter +import org.loculus.backend.service.LoculusUser +import org.loculus.backend.service.UserDirectory import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.test.web.servlet.ResultActions @@ -36,11 +36,11 @@ import java.util.UUID @EndpointTest class GroupManagementControllerTest(@Autowired private val client: GroupManagementControllerClient) { @MockkBean - lateinit var keycloakAdapter: KeycloakAdapter + lateinit var userDirectory: UserDirectory @BeforeEach fun setup() { - every { keycloakAdapter.getUsersWithName(any()) } returns listOf(UserRepresentation()) + every { userDirectory.getUsersWithName(any()) } returns listOf(LoculusUser("dummy", null, null, null, null)) } @Test @@ -260,7 +260,7 @@ class GroupManagementControllerTest(@Autowired private val client: GroupManageme @Test fun `GIVEN a group WHEN I add a user that does not exists to the group THEN expect user is not found`() { - every { keycloakAdapter.getUsersWithName(any()) } returns listOf() + every { userDirectory.getUsersWithName(any()) } returns listOf() val otherUser = "otherUserThatDoesNotExist" diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/AuthorsEndpointsTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/AuthorsEndpointsTest.kt index 23162a3d50..00c94b1333 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/AuthorsEndpointsTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/AuthorsEndpointsTest.kt @@ -3,9 +3,9 @@ package org.loculus.backend.controller.seqsetcitations import com.ninjasquad.springmockk.MockkBean import io.mockk.every import org.junit.jupiter.api.Test -import org.keycloak.representations.idm.UserRepresentation import org.loculus.backend.controller.EndpointTest -import org.loculus.backend.service.KeycloakAdapter +import org.loculus.backend.service.LoculusUser +import org.loculus.backend.service.UserDirectory import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content @@ -16,11 +16,11 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status class AuthorsEndpointsTest(@Autowired private val client: SeqSetCitationsControllerClient) { @MockkBean - lateinit var keycloakAdapter: KeycloakAdapter + lateinit var userDirectory: UserDirectory @Test fun `WHEN calling get author profile of non-existing user THEN returns not found`() { - every { keycloakAdapter.getUsersWithName(any()) } returns listOf() + every { userDirectory.getUsersWithName(any()) } returns listOf() client.getAuthor(username = MOCK_USERNAME) .andExpect(status().isNotFound) .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) @@ -29,13 +29,14 @@ class AuthorsEndpointsTest(@Autowired private val client: SeqSetCitationsControl @Test fun `WHEN calling get author profile of existing user THEN returns author profile`() { - val mockUser = UserRepresentation() - mockUser.setUsername(MOCK_USERNAME) - mockUser.setEmail(MOCK_USER_EMAIL) - mockUser.setFirstName(MOCK_USER_FIRST_NAME) - mockUser.setLastName(MOCK_USER_LAST_NAME) - mockUser.setAttributes(mapOf("university" to listOf(MOCK_USER_UNIVERSITY))) - every { keycloakAdapter.getUsersWithName(any()) } returns listOf(mockUser) + val mockUser = LoculusUser( + username = MOCK_USERNAME, + email = MOCK_USER_EMAIL, + firstName = MOCK_USER_FIRST_NAME, + lastName = MOCK_USER_LAST_NAME, + organization = MOCK_USER_UNIVERSITY, + ) + every { userDirectory.getUsersWithName(any()) } returns listOf(mockUser) val emailDomain = MOCK_USER_EMAIL.split("@").last() client.getAuthor(username = MOCK_USERNAME) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataDataUseTermsDisabledEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataDataUseTermsDisabledEndpointTest.kt index 3615f42a2b..d6422e6d94 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataDataUseTermsDisabledEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataDataUseTermsDisabledEndpointTest.kt @@ -13,7 +13,6 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.matchesPattern import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.keycloak.representations.idm.UserRepresentation import org.loculus.backend.api.GeneticSequence import org.loculus.backend.api.ProcessedData import org.loculus.backend.config.BackendConfig @@ -30,7 +29,8 @@ import org.loculus.backend.controller.groupmanagement.GroupManagementControllerC import org.loculus.backend.controller.groupmanagement.andGetGroupId import org.loculus.backend.controller.jwtForDefaultUser import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.NUMBER_OF_SEQUENCES -import org.loculus.backend.service.KeycloakAdapter +import org.loculus.backend.service.LoculusUser +import org.loculus.backend.service.UserDirectory import org.loculus.backend.utils.DateProvider import org.springframework.beans.factory.annotation.Autowired import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header @@ -49,11 +49,11 @@ class GetReleasedDataDataUseTermsDisabledEndpointTest( private val currentDate = Clock.System.now().toLocalDateTime(DateProvider.timeZone).date.toString() @MockkBean - lateinit var keycloakAdapter: KeycloakAdapter + lateinit var userDirectory: UserDirectory @BeforeEach fun setup() { - every { keycloakAdapter.getUsersWithName(any()) } returns listOf(UserRepresentation()) + every { userDirectory.getUsersWithName(any()) } returns listOf(LoculusUser("dummy", null, null, null, null)) } @Test diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt index 68199eaada..3bbaa6e503 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt @@ -35,7 +35,6 @@ import org.jetbrains.exposed.sql.batchInsert import org.jetbrains.exposed.sql.transactions.transaction import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.keycloak.representations.idm.UserRepresentation import org.loculus.backend.api.AccessionVersionInterface import org.loculus.backend.api.DataUseTerms import org.loculus.backend.api.DataUseTermsChangeRequest @@ -63,7 +62,8 @@ import org.loculus.backend.controller.jacksonObjectMapper import org.loculus.backend.controller.jwtForDefaultUser import org.loculus.backend.controller.submission.GetReleasedDataEndpointWithDataUseTermsUrlTest.GetReleasedDataEndpointWithDataUseTermsUrlTestConfig import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.NUMBER_OF_SEQUENCES -import org.loculus.backend.service.KeycloakAdapter +import org.loculus.backend.service.LoculusUser +import org.loculus.backend.service.UserDirectory import org.loculus.backend.service.submission.SequenceEntriesTable import org.loculus.backend.service.submission.SubmissionDatabaseService import org.loculus.backend.utils.Accession @@ -97,11 +97,11 @@ class GetReleasedDataEndpointTest( private val currentDate = Clock.System.now().toLocalDateTime(DateProvider.timeZone).date.toString() @MockkBean - lateinit var keycloakAdapter: KeycloakAdapter + lateinit var userDirectory: UserDirectory @BeforeEach fun setup() { - every { keycloakAdapter.getUsersWithName(any()) } returns listOf(UserRepresentation()) + every { userDirectory.getUsersWithName(any()) } returns listOf(LoculusUser("dummy", null, null, null, null)) } @Test diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetSequencesEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetSequencesEndpointTest.kt index 20a9b6fb3c..1ffc196a4f 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetSequencesEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetSequencesEndpointTest.kt @@ -14,7 +14,6 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource -import org.keycloak.representations.idm.UserRepresentation import org.loculus.backend.api.ProcessingResult import org.loculus.backend.api.Status import org.loculus.backend.api.Status.APPROVED_FOR_RELEASE @@ -35,7 +34,8 @@ import org.loculus.backend.controller.groupmanagement.GroupManagementControllerC import org.loculus.backend.controller.groupmanagement.andGetGroupId import org.loculus.backend.controller.jwtForSuperUser import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.NUMBER_OF_SEQUENCES -import org.loculus.backend.service.KeycloakAdapter +import org.loculus.backend.service.LoculusUser +import org.loculus.backend.service.UserDirectory import org.loculus.backend.utils.Accession import org.springframework.beans.factory.annotation.Autowired import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath @@ -48,11 +48,11 @@ class GetSequencesEndpointTest( @Autowired private val groupManagementClient: GroupManagementControllerClient, ) { @MockkBean - lateinit var keycloakAdapter: KeycloakAdapter + lateinit var userDirectory: UserDirectory @BeforeEach fun setup() { - every { keycloakAdapter.getUsersWithName(any()) } returns listOf(UserRepresentation()) + every { userDirectory.getUsersWithName(any()) } returns listOf(LoculusUser("dummy", null, null, null, null)) } @Test diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties index 4961ec5b3e..bbfe28b258 100644 --- a/backend/src/test/resources/application.properties +++ b/backend/src/test/resources/application.properties @@ -11,10 +11,13 @@ crossref.email=dois@loculus.org crossref.organization=loculus.org crossref.host-url=https://main.loculus.org -keycloak.user=dummy -keycloak.password=dummy -keycloak.realm=dummyRealm -keycloak.client=dummy-cli -keycloak.url=dummy:420 +loculus.ldap.host=localhost +loculus.ldap.port=3890 +loculus.ldap.base-dn=dc=loculus,dc=org +loculus.ldap.user-base-dn=ou=people,dc=loculus,dc=org +loculus.ldap.group-base-dn=ou=groups,dc=loculus,dc=org +loculus.ldap.user-filter=(&(uid={0})(objectClass=person)) +loculus.ldap.bind-dn=uid=admin,ou=people,dc=loculus,dc=org +loculus.ldap.bind-password=dummy spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://some.value diff --git a/cli/src/loculus_cli/api/backend.py b/cli/src/loculus_cli/api/backend.py index d5f9d79405..e47f79ad3e 100644 --- a/cli/src/loculus_cli/api/backend.py +++ b/cli/src/loculus_cli/api/backend.py @@ -7,6 +7,7 @@ from ..auth.client import AuthClient from ..config import InstanceConfig +from ..local_dev import verify_tls from .models import ( AccessionVersion, GroupInfo, @@ -26,6 +27,7 @@ def __init__(self, instance_config: InstanceConfig, auth_client: AuthClient): base_url=instance_config.backend_url, timeout=30.0, follow_redirects=True, + verify=verify_tls(), ) def _get_headers(self, username: str) -> dict[str, str]: diff --git a/cli/src/loculus_cli/api/lapis.py b/cli/src/loculus_cli/api/lapis.py index 2849fcc3f7..ccafd96948 100644 --- a/cli/src/loculus_cli/api/lapis.py +++ b/cli/src/loculus_cli/api/lapis.py @@ -5,6 +5,7 @@ import httpx from pydantic import ValidationError +from ..local_dev import verify_tls from ..utils.console import get_stderr_console from .models import LapisAggregatedResponse, LapisResponse, LapisSequenceResponse @@ -18,6 +19,7 @@ def __init__(self, lapis_url: str): base_url=lapis_url, timeout=60.0, # LAPIS queries can take longer follow_redirects=True, + verify=verify_tls(), ) self.stderr_console = get_stderr_console() diff --git a/cli/src/loculus_cli/auth/client.py b/cli/src/loculus_cli/auth/client.py index 48d3644b82..09f9c05743 100644 --- a/cli/src/loculus_cli/auth/client.py +++ b/cli/src/loculus_cli/auth/client.py @@ -1,246 +1,310 @@ -"""Authentication client for Keycloak integration.""" +"""Authentication client for the Loculus CLI. + +The CLI talks to Authelia's OIDC provider using the device authorization grant +(RFC 8628). Authelia does not support the Resource Owner Password Credentials +grant, so there is no longer a username+password login codepath. + +Tokens are cached in the system keyring keyed by the Authelia URL + the +authenticated subject. Refresh tokens are used opportunistically to avoid +prompting the user to re-authorize on every command. +""" import os import time +import webbrowser +from typing import TYPE_CHECKING, Any import httpx import keyring from pydantic import BaseModel +from rich.console import Console + +from ..local_dev import verify_tls -from ..config import InstanceConfig +if TYPE_CHECKING: + from ..config import InstanceConfig class TokenInfo(BaseModel): - """Token information from Keycloak.""" + """OIDC tokens returned by the Authelia token endpoint.""" access_token: str - refresh_token: str + refresh_token: str | None = None expires_in: int - refresh_expires_in: int - token_type: str - created_at: float # Unix timestamp when token was created + refresh_expires_in: int = 0 + token_type: str = "Bearer" + id_token: str | None = None + subject: str | None = None + created_at: float + + +class DeviceCodeError(RuntimeError): + """Raised when device-code authorization fails or is denied.""" + + +_CONSOLE = Console() class AuthClient: - """Authentication client for Keycloak.""" + """OIDC device-code authentication client.""" - def __init__(self, instance_config: InstanceConfig): + def __init__(self, instance_config: "InstanceConfig") -> None: self.instance_config = instance_config - self.client = httpx.Client(timeout=30.0) + self.client = httpx.Client(timeout=30.0, verify=verify_tls()) self._service_name = os.getenv("LOCULUS_CLI_KEYRING_SERVICE", "loculus-cli") self._token_cache: TokenInfo | None = None + self._discovery_cache: dict[str, str] | None = None + + # ---------------------------------------------------------------- keyring - def _get_keyring_key(self, username: str) -> str: - """Get keyring key for storing tokens.""" - return f"{self.instance_config.keycloak_url}#{username}" + def _key(self, subject: str) -> str: + return f"{self.instance_config.authelia_url}#{subject}" - def _store_token(self, username: str, token_info: TokenInfo) -> None: - """Store token in keyring.""" + def _store_token(self, subject: str, token_info: TokenInfo) -> None: try: keyring.set_password( self._service_name, - self._get_keyring_key(username), + self._key(subject), token_info.model_dump_json(), ) except Exception as e: raise RuntimeError(f"Failed to store token: {e}") from e - def _load_token(self, username: str) -> TokenInfo | None: - """Load token from keyring.""" + def _load_token(self, subject: str) -> TokenInfo | None: try: - token_data = keyring.get_password( - self._service_name, self._get_keyring_key(username) - ) - if token_data: - return TokenInfo.model_validate_json(token_data) - return None + blob = keyring.get_password(self._service_name, self._key(subject)) + if blob: + return TokenInfo.model_validate_json(blob) except Exception: return None + return None - def _delete_token(self, username: str) -> None: - """Delete token from keyring.""" + def _delete_token(self, subject: str) -> None: try: - keyring.delete_password(self._service_name, self._get_keyring_key(username)) + keyring.delete_password(self._service_name, self._key(subject)) except Exception: - pass # Ignore errors when deleting + pass + + # ---------------------------------------------------------------- expiry - def _is_token_expired(self, token_info: TokenInfo) -> bool: - """Check if token is expired.""" - current_time = time.time() - # Consider token expired if it expires in less than 5 minutes - return (token_info.created_at + token_info.expires_in - 300) < current_time + def _is_access_token_expired(self, token_info: TokenInfo) -> bool: + # Treat as expired 5 minutes early to give callers headroom. + return (token_info.created_at + token_info.expires_in - 300) < time.time() def _is_refresh_token_expired(self, token_info: TokenInfo) -> bool: - """Check if refresh token is expired.""" - current_time = time.time() - return (token_info.created_at + token_info.refresh_expires_in) < current_time - - def login(self, username: str, password: str) -> TokenInfo: - """Login with username and password.""" - token_url = ( - f"{self.instance_config.keycloak_url}/realms/" - f"{self.instance_config.keycloak_realm}/protocol/openid-connect/token" + if token_info.refresh_expires_in <= 0: + return False + return (token_info.created_at + token_info.refresh_expires_in) < time.time() + + # ---------------------------------------------------------------- oidc + + def _discovery(self) -> dict[str, str]: + if self._discovery_cache is not None: + return self._discovery_cache + base = self.instance_config.authelia_url.rstrip("/") + url = f"{base}/.well-known/openid-configuration" + resp = self.client.get(url) + resp.raise_for_status() + self._discovery_cache = resp.json() + return self._discovery_cache + + def login(self) -> TokenInfo: + """Interactive device-code login. Returns the resulting tokens. + + Prints the verification URL and user code to stderr and (best-effort) + opens the browser. Polls the token endpoint until the user finishes + authentication or the device code expires. + """ + disc = self._discovery() + device_endpoint = disc.get( + "device_authorization_endpoint", + f"{self.instance_config.authelia_url.rstrip('/')}/api/oidc/device-authorization", ) + token_endpoint = disc["token_endpoint"] + + resp = self.client.post( + device_endpoint, + data={ + "client_id": self.instance_config.oidc_client_id, + "scope": "openid profile email groups offline_access", + }, + ) + if resp.status_code != 200: + raise DeviceCodeError( + "Device authorization request failed: " + f"HTTP {resp.status_code} {resp.text}" + ) + body = resp.json() - data = { - "grant_type": "password", - "client_id": self.instance_config.keycloak_client_id, - "username": username, - "password": password, - } - + verification_uri = ( + body.get("verification_uri_complete") or body["verification_uri"] + ) + device_code = body["device_code"] + user_code = body.get("user_code") + interval = int(body.get("interval", 5)) + expires_in = int(body.get("expires_in", 600)) + + _CONSOLE.print( + f"To sign in, visit [bold]{verification_uri}[/bold]" + + (f" and enter the code [bold]{user_code}[/bold]" if user_code else "") + ) try: - response = self.client.post(token_url, data=data) - response.raise_for_status() - - token_data = response.json() - token_info = TokenInfo( - access_token=token_data["access_token"], - refresh_token=token_data["refresh_token"], - expires_in=token_data["expires_in"], - refresh_expires_in=token_data["refresh_expires_in"], - token_type=token_data.get("token_type", "Bearer"), - created_at=time.time(), - ) + webbrowser.open(verification_uri, new=2) + except Exception: + pass - # Store token in keyring - self._store_token(username, token_info) - self._token_cache = token_info - - return token_info - - except httpx.HTTPStatusError as e: - if e.response.status_code == 401: - raise RuntimeError("Invalid username or password") from e - elif e.response.status_code == 400: - # Try to get more specific error message - try: - error_data = e.response.json() - error_description = error_data.get( - "error_description", "Bad request" - ) - raise RuntimeError( - f"Authentication failed: {error_description}" - ) from e - except Exception: - raise RuntimeError("Authentication failed: Bad request") from e - else: - raise RuntimeError( - f"Authentication failed: HTTP {e.response.status_code}" - ) from e - except Exception as e: - raise RuntimeError(f"Authentication failed: {e}") from e + deadline = time.time() + expires_in + while time.time() < deadline: + time.sleep(interval) + poll = self.client.post( + token_endpoint, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + "client_id": self.instance_config.oidc_client_id, + }, + ) + if poll.status_code == 200: + token = self._token_info_from_response(poll.json()) + subject = self._subject_for(token) + self._store_token(subject, token) + self._token_cache = token + self.set_current_user(subject) + return token + err = poll.json().get("error", "") + if err == "authorization_pending": + continue + if err == "slow_down": + interval += 5 + continue + if err in ("expired_token", "access_denied"): + raise DeviceCodeError(f"Device authorization failed: {err}") + raise DeviceCodeError( + f"Unexpected device-code response: HTTP {poll.status_code} {poll.text}" + ) + raise DeviceCodeError("Device code expired before login completed") - def refresh_token(self, username: str) -> TokenInfo | None: - """Refresh access token using refresh token.""" - token_info = self._load_token(username) - if not token_info: + def refresh_token(self, subject: str) -> TokenInfo | None: + """Refresh tokens for `subject`. Returns None on failure.""" + token_info = self._load_token(subject) + if not token_info or not token_info.refresh_token: return None - if self._is_refresh_token_expired(token_info): - # Refresh token is expired, need to login again - self._delete_token(username) + self._delete_token(subject) return None - token_url = ( - f"{self.instance_config.keycloak_url}/realms/" - f"{self.instance_config.keycloak_realm}/protocol/openid-connect/token" - ) - - data = { - "grant_type": "refresh_token", - "client_id": self.instance_config.keycloak_client_id, - "refresh_token": token_info.refresh_token, - } - + token_endpoint = self._discovery()["token_endpoint"] try: - response = self.client.post(token_url, data=data) - response.raise_for_status() - - token_data = response.json() - new_token_info = TokenInfo( - access_token=token_data["access_token"], - refresh_token=token_data.get("refresh_token", token_info.refresh_token), - expires_in=token_data["expires_in"], - refresh_expires_in=token_data.get( - "refresh_expires_in", token_info.refresh_expires_in - ), - token_type=token_data.get("token_type", "Bearer"), - created_at=time.time(), + resp = self.client.post( + token_endpoint, + data={ + "grant_type": "refresh_token", + "client_id": self.instance_config.oidc_client_id, + "refresh_token": token_info.refresh_token, + }, ) + resp.raise_for_status() + new_token = self._token_info_from_response(resp.json(), default=token_info) + self._store_token(subject, new_token) + self._token_cache = new_token + return new_token + except Exception: + self._delete_token(subject) + return None - # Store updated token - self._store_token(username, new_token_info) - self._token_cache = new_token_info + def _token_info_from_response( + self, data: dict[str, Any], default: TokenInfo | None = None + ) -> TokenInfo: + return TokenInfo( + access_token=data["access_token"], + refresh_token=data.get( + "refresh_token", default.refresh_token if default else None + ), + expires_in=int( + data.get("expires_in", default.expires_in if default else 3600) + ), + refresh_expires_in=int( + data.get( + "refresh_expires_in", + default.refresh_expires_in if default else 0, + ) + ), + token_type=data.get("token_type", "Bearer"), + id_token=data.get("id_token", default.id_token if default else None), + subject=default.subject if default else None, + created_at=time.time(), + ) - return new_token_info + def _subject_for(self, token: TokenInfo) -> str: + # Best-effort: decode JWT to find the subject. Without verification — + # the keyring key just needs to be stable per user. + import base64 + import json + if token.subject: + return token.subject + try: + _, payload, _ = token.access_token.split(".") + padded = payload + "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(padded)) except Exception: - # If refresh fails, delete the token - self._delete_token(username) - return None + return "current" + sub = claims.get("preferred_username") or claims.get("sub") or "current" + token.subject = sub + return sub - def get_valid_token(self, username: str) -> TokenInfo | None: - """Get a valid access token, refreshing if necessary.""" - # Try cache first - if self._token_cache and not self._is_token_expired(self._token_cache): - return self._token_cache + # ---------------------------------------------------------------- public - # Load from keyring - token_info = self._load_token(username) - if not token_info: + def get_valid_token(self, subject: str | None = None) -> TokenInfo | None: + sub = subject or self.get_current_user() + if sub is None: return None - - # Check if token is expired - if self._is_token_expired(token_info): - # Try to refresh - token_info = self.refresh_token(username) - if not token_info: + if self._token_cache and not self._is_access_token_expired(self._token_cache): + return self._token_cache + token = self._load_token(sub) + if not token: + return None + if self._is_access_token_expired(token): + token = self.refresh_token(sub) + if not token: return None + self._token_cache = token + return token - self._token_cache = token_info - return token_info - - def logout(self, username: str) -> None: - """Logout and clear stored token.""" - self._delete_token(username) + def logout(self, subject: str | None = None) -> None: + sub = subject or self.get_current_user() + if sub: + self._delete_token(sub) self._token_cache = None + self.clear_current_user() - def get_auth_headers(self, username: str) -> dict[str, str]: - """Get authentication headers for API requests.""" - token_info = self.get_valid_token(username) - if not token_info: + def get_auth_headers(self, subject: str | None = None) -> dict[str, str]: + token = self.get_valid_token(subject) + if not token: raise RuntimeError( "Not authenticated. Please run 'loculus auth login' first." ) + return {"Authorization": f"{token.token_type} {token.access_token}"} - return {"Authorization": f"{token_info.token_type} {token_info.access_token}"} - - def is_authenticated(self, username: str) -> bool: - """Check if user is authenticated.""" - return self.get_valid_token(username) is not None + def is_authenticated(self, subject: str | None = None) -> bool: + return self.get_valid_token(subject) is not None def get_current_user(self) -> str | None: - """Get current authenticated user.""" - # For now, we'll need to store the username separately - # This is a limitation of the current design try: username = keyring.get_password(self._service_name, "current_user") if username and self.is_authenticated(username): return username - return None except Exception: return None + return None def set_current_user(self, username: str) -> None: - """Set current authenticated user.""" try: keyring.set_password(self._service_name, "current_user", username) except Exception as e: raise RuntimeError(f"Failed to store current user: {e}") from e def clear_current_user(self) -> None: - """Clear current authenticated user.""" try: keyring.delete_password(self._service_name, "current_user") except Exception: diff --git a/cli/src/loculus_cli/cli.py b/cli/src/loculus_cli/cli.py index 44b0a6c1e0..754c5de2d8 100644 --- a/cli/src/loculus_cli/cli.py +++ b/cli/src/loculus_cli/cli.py @@ -16,6 +16,9 @@ from .commands.status import status from .commands.submit import submit_group from .config import check_and_show_warning +from .local_dev import install_local_test_dns + +install_local_test_dns() console = Console() diff --git a/cli/src/loculus_cli/commands/auth.py b/cli/src/loculus_cli/commands/auth.py index 4902f121bc..c1eb44d744 100644 --- a/cli/src/loculus_cli/commands/auth.py +++ b/cli/src/loculus_cli/commands/auth.py @@ -2,7 +2,6 @@ import click from rich.console import Console -from rich.prompt import Prompt from ..auth.client import AuthClient from ..config import get_instance_config @@ -19,40 +18,21 @@ def auth_group() -> None: @auth_group.command() -@click.option( - "--username", - "-u", - help="Username for authentication", -) -@click.option( - "--password", - "-p", - help="Password for authentication", -) @click.pass_context -def login(ctx: click.Context, username: str, password: str) -> None: - """Login to Loculus.""" +def login(ctx: click.Context) -> None: + """Login to Loculus using the OIDC device-code flow.""" instance = require_instance(ctx, ctx.obj.get("instance")) instance_config = get_instance_config(instance) - # Display instance information console.print(f"[dim]Logging into instance: {instance}[/dim]") - # Prompt for credentials if not provided - if not username: - username = Prompt.ask("Username") - if not password: - password = Prompt.ask("Password", password=True) - auth_client = AuthClient(instance_config) try: - with console.status("Logging in..."): - token_info = auth_client.login(username, password) - auth_client.set_current_user(username) - + token_info = auth_client.login() + current_user = auth_client.get_current_user() or "current" console.print( - f"✓ Successfully logged in as [bold green]{username}[/bold green]" + f"✓ Successfully logged in as [bold green]{current_user}[/bold green]" ) console.print(f"Instance: [bold cyan]{instance}[/bold cyan]") console.print(f"Token expires in {token_info.expires_in // 60} minutes") @@ -74,7 +54,6 @@ def logout(ctx: click.Context) -> None: current_user = auth_client.get_current_user() if current_user: auth_client.logout(current_user) - auth_client.clear_current_user() console.print( f"✓ Successfully logged out [bold green]{current_user}[/bold green]" ) diff --git a/cli/src/loculus_cli/commands/instance.py b/cli/src/loculus_cli/commands/instance.py index 8620ed0777..c09ea74c6e 100644 --- a/cli/src/loculus_cli/commands/instance.py +++ b/cli/src/loculus_cli/commands/instance.py @@ -121,16 +121,16 @@ def select_instance(name: str | None, none: bool) -> None: @click.argument("url") @click.option("--name", help="Custom name for the instance (default: derived from URL)") @click.option("--set-default", is_flag=True, help="Set as default instance") -@click.option("--keycloak-realm", default="loculus", help="Keycloak realm") @click.option( - "--keycloak-client-id", default="backend-client", help="Keycloak client ID" + "--oidc-client-id", + default="loculus-cli", + help="OIDC client ID used for device-code login", ) def add_instance( url: str, name: str | None, set_default: bool, - keycloak_realm: str, - keycloak_client_id: str, + oidc_client_id: str, ) -> None: """Add a new instance.""" try: @@ -161,8 +161,7 @@ def add_instance( config = load_config() config.instances[name] = InstanceConfig( instance_url=url, - keycloak_realm=keycloak_realm, - keycloak_client_id=keycloak_client_id, + oidc_client_id=oidc_client_id, ) if set_default or not config.default_instance: @@ -228,8 +227,7 @@ def show_instance(name: str | None) -> None: # Show configuration console.print("[bold]Configuration:[/bold]") console.print(f" URL: {instance_config.instance_url}") - console.print(f" Keycloak Realm: {instance_config.keycloak_realm}") - console.print(f" Keycloak Client ID: {instance_config.keycloak_client_id}") + console.print(f" OIDC Client ID: {instance_config.oidc_client_id}") # Try to fetch live info try: diff --git a/cli/src/loculus_cli/config.py b/cli/src/loculus_cli/config.py index 1aff80767b..7c676897e6 100644 --- a/cli/src/loculus_cli/config.py +++ b/cli/src/loculus_cli/config.py @@ -18,9 +18,8 @@ class InstanceConfig(BaseModel): """Configuration for a specific Loculus instance.""" instance_url: str = Field(description="Base instance URL") - keycloak_realm: str = Field(default="loculus", description="Keycloak realm") - keycloak_client_id: str = Field( - default="backend-client", description="Keycloak client ID" + oidc_client_id: str = Field( + default="loculus-cli", description="OIDC client ID used for device-code login" ) _instance_info: InstanceInfo | None = None @@ -38,9 +37,9 @@ def backend_url(self) -> str: return self.instance_info.get_hosts()["backend"] @property - def keycloak_url(self) -> str: - """Get Keycloak URL dynamically.""" - return self.instance_info.get_hosts()["keycloak"] + def authelia_url(self) -> str: + """Get the authentication base URL dynamically.""" + return self.instance_info.get_hosts()["authelia"] @property def website_url(self) -> str: diff --git a/cli/src/loculus_cli/instance_info.py b/cli/src/loculus_cli/instance_info.py index 9d709f90d7..5c4353618e 100644 --- a/cli/src/loculus_cli/instance_info.py +++ b/cli/src/loculus_cli/instance_info.py @@ -5,6 +5,7 @@ import httpx +from .local_dev import verify_tls from .types import Schema @@ -30,7 +31,7 @@ def get_info(self) -> dict[str, Any]: return self._cache try: - with httpx.Client(timeout=30.0) as client: + with httpx.Client(timeout=30.0, verify=verify_tls()) as client: response = client.get(f"{self.instance_url}/loculus-info") response.raise_for_status() @@ -47,7 +48,7 @@ def get_info(self) -> dict[str, Any]: raise RuntimeError(f"Error fetching instance info: {e}") from e def get_hosts(self) -> dict[str, str]: - """Get host URLs for backend, keycloak, website.""" + """Get host URLs for backend, authelia, website.""" info = self.get_info() if "hosts" not in info: raise RuntimeError("Instance info missing 'hosts' section") diff --git a/cli/src/loculus_cli/local_dev.py b/cli/src/loculus_cli/local_dev.py new file mode 100644 index 0000000000..b774dbcec7 --- /dev/null +++ b/cli/src/loculus_cli/local_dev.py @@ -0,0 +1,53 @@ +"""Helpers for local development and integration-test networking.""" + +import os +import socket +from typing import Any, cast + +LOCAL_TEST_DOMAIN_SUFFIX = ".loculus.test" +LOCAL_TEST_DOMAIN = "loculus.test" + +_ORIGINAL_GETADDRINFO = socket.getaddrinfo +_DNS_PATCHED = False + + +def _env_enabled(name: str) -> bool: + return os.getenv(name, "").lower() in {"1", "true", "yes", "on"} + + +def local_test_dns_enabled() -> bool: + return _env_enabled("LOCULUS_CLI_LOCAL_TEST_DNS") + + +def verify_tls() -> bool: + return not _env_enabled("LOCULUS_CLI_ALLOW_INSECURE_LOCAL_TEST_TLS") + + +def _is_local_test_host(host: object) -> bool: + return isinstance(host, str) and ( + host == LOCAL_TEST_DOMAIN or host.endswith(LOCAL_TEST_DOMAIN_SUFFIX) + ) + + +def install_local_test_dns() -> None: + """Resolve loculus.test names to localhost when explicitly enabled.""" + global _DNS_PATCHED + if _DNS_PATCHED or not local_test_dns_enabled(): + return + + def getaddrinfo( + host: bytes | str | None, + port: bytes | str | int | None, + family: int = 0, + socktype: int = 0, + proto: int = 0, + flags: int = 0, + ) -> list[tuple[Any, ...]]: + if _is_local_test_host(host): + return _ORIGINAL_GETADDRINFO( + "127.0.0.1", port, family, socktype, proto, flags + ) + return _ORIGINAL_GETADDRINFO(host, port, family, socktype, proto, flags) + + socket.getaddrinfo = cast(Any, getaddrinfo) + _DNS_PATCHED = True diff --git a/cli/src/loculus_cli/utils/review_utils.py b/cli/src/loculus_cli/utils/review_utils.py index 7b93a571ac..d6fef33d61 100644 --- a/cli/src/loculus_cli/utils/review_utils.py +++ b/cli/src/loculus_cli/utils/review_utils.py @@ -10,6 +10,7 @@ from ..auth.client import AuthClient from ..config import InstanceConfig +from ..local_dev import verify_tls class SequenceStatus(str, Enum): @@ -177,6 +178,7 @@ def get_sequences( f"{backend_url}/{organism}/get-sequences", headers=self._get_auth_headers(), params=params, + verify=verify_tls(), ) response.raise_for_status() @@ -191,6 +193,7 @@ def get_sequence_details( response = httpx.get( f"{backend_url}/{organism}/get-data-to-edit/{accession}/{version}", headers=self._get_auth_headers(), + verify=verify_tls(), ) response.raise_for_status() @@ -218,6 +221,7 @@ def approve_sequences( f"{backend_url}/{organism}/approve-processed-data", headers=self._get_auth_headers(), json=data, + verify=verify_tls(), ) response.raise_for_status() diff --git a/deploy.py b/deploy.py index 332c2efce0..85c1272b94 100755 --- a/deploy.py +++ b/deploy.py @@ -42,7 +42,9 @@ BACKEND_PORT_MAPPING = "-p 127.0.0.1:8079:30082@agent:0" LAPIS_PORT_MAPPING = "-p 127.0.0.1:8080:80@loadbalancer" DATABASE_PORT_MAPPING = "-p 127.0.0.1:5432:30432@agent:0" -KEYCLOAK_PORT_MAPPING = "-p 127.0.0.1:8083:30083@agent:0" +# Authelia is routed via traefik on HTTPS so its cookie+url validation +# accepts the configuration. 8443 → 443 on the traefik loadbalancer. +AUTHELIA_HTTPS_PORT_MAPPING = "-p 127.0.0.1:8443:443@loadbalancer" S3_PORT_MAPPING = "-p 127.0.0.1:8084:30084@agent:0" PORTS = [ @@ -50,7 +52,7 @@ BACKEND_PORT_MAPPING, LAPIS_PORT_MAPPING, DATABASE_PORT_MAPPING, - KEYCLOAK_PORT_MAPPING, + AUTHELIA_HTTPS_PORT_MAPPING, S3_PORT_MAPPING, ] diff --git a/ena-submission/src/ena_deposition/call_loculus.py b/ena-submission/src/ena_deposition/call_loculus.py index 5bc78d637d..2372a36c7f 100644 --- a/ena-submission/src/ena_deposition/call_loculus.py +++ b/ena-submission/src/ena_deposition/call_loculus.py @@ -24,28 +24,13 @@ def organism_url(config: Config, organism: str) -> str: return f"{backend_url(config)}/{organism.strip('/')}" -def get_jwt(config: Config) -> str: - """Get a JWT token for the given username and password""" - - external_metadata_updater_password = os.getenv("EXTERNAL_METADATA_UPDATER_PASSWORD") - if not external_metadata_updater_password: - external_metadata_updater_password = config.password - - data = { - "username": config.username, - "password": external_metadata_updater_password, - "grant_type": "password", - "client_id": config.keycloak_client_id, - } - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - keycloak_token_url = config.keycloak_token_url - - response = requests.post(keycloak_token_url, data=data, headers=headers, timeout=60) - response.raise_for_status() +def service_token(config: Config) -> str: + """The pre-shared service token for the external metadata updater. - jwt_keycloak = response.json() - return jwt_keycloak["access_token"] + Authelia OIDC does not support OAuth2 ROPC. Service-to-service auth + now uses an X-Service-Token header against the backend's static filter. + """ + return os.getenv("EXTERNAL_METADATA_UPDATER_PASSWORD") or config.password def make_request( @@ -63,8 +48,7 @@ def make_request( if headers is None: headers = {} if auth: - jwt = get_jwt(config) - headers["Authorization"] = f"Bearer {jwt}" + headers["X-Service-Token"] = service_token(config) match method: case HTTPMethod.GET: diff --git a/ingest/scripts/loculus_client.py b/ingest/scripts/loculus_client.py index bac23877bc..2befc69e86 100644 --- a/ingest/scripts/loculus_client.py +++ b/ingest/scripts/loculus_client.py @@ -47,35 +47,14 @@ def organism_url(config: ApproveConfig) -> str: return f"{backend_url(config)}/{config.organism.strip('/')}" -def get_jwt(config: ApproveConfig) -> str: - """ - Get a JWT token for the given username and password - """ - - keycloak_ingest_password = os.getenv("KEYCLOAK_INGEST_PASSWORD") - if not keycloak_ingest_password: - keycloak_ingest_password = config.password +def service_token(config: ApproveConfig) -> str: + """The pre-shared service token for the ingest user. - data = { - "username": config.username, - "password": keycloak_ingest_password, - "grant_type": "password", - "client_id": config.keycloak_client_id, - } - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - keycloak_token_url = config.keycloak_token_url - - response = requests.post( - keycloak_token_url, - data=data, - headers=headers, - timeout=config.backend_request_timeout_seconds, - ) - response.raise_for_status() - - jwt_keycloak = response.json() - return jwt_keycloak["access_token"] + The Authelia OIDC provider doesn't support OAuth2 ROPC, so service-to- + service authentication uses an X-Service-Token header against the + backend's static filter instead of obtaining a JWT. + """ + return os.getenv("KEYCLOAK_INGEST_PASSWORD") or config.password def make_request( # noqa: PLR0913, PLR0917 @@ -89,8 +68,7 @@ def make_request( # noqa: PLR0913, PLR0917 """ Generic request function to handle repetitive tasks like fetching JWT and setting headers. """ - jwt = get_jwt(config) - headers = {"Authorization": f"Bearer {jwt}", "Content-Type": "application/json"} + headers = {"X-Service-Token": service_token(config), "Content-Type": "application/json"} timeout = config.backend_request_timeout_seconds match method: case HTTPMethod.GET: diff --git a/integration-tests/playwright.config.ts b/integration-tests/playwright.config.ts index e755043d66..35a6bb18df 100644 --- a/integration-tests/playwright.config.ts +++ b/integration-tests/playwright.config.ts @@ -30,8 +30,16 @@ const config = { use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:3000', - /* Ignore HTTPS errors when requested via environment variable. */ - ignoreHTTPSErrors: process.env.PLAYWRIGHT_TEST_IGNORE_HTTPS_ERRORS === 'true', + /* Authelia is served over HTTPS via traefik using a self-signed cert in + * dev/CI; production deployments use a real cert. Always accept. */ + ignoreHTTPSErrors: true, + /* Map the *.loculus.test dev domain to localhost without needing an + * /etc/hosts entry on the host. */ + launchOptions: { + args: [ + '--host-resolver-rules=MAP *.loculus.test 127.0.0.1, MAP loculus.test 127.0.0.1', + ], + }, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: (process.env.CI ? 'retain-on-failure' : 'on') as diff --git a/integration-tests/tests/fixtures/cli.fixture.ts b/integration-tests/tests/fixtures/cli.fixture.ts index 5dde438bba..43bc77c911 100644 --- a/integration-tests/tests/fixtures/cli.fixture.ts +++ b/integration-tests/tests/fixtures/cli.fixture.ts @@ -4,10 +4,10 @@ import { TestCliPage } from '../pages/CliPage'; export const cliTest = test.extend<{ cliPage: TestCliPage; }>({ - cliPage: async ({ groupName, groupId, testAccount }, use) => { + cliPage: async ({ page, groupName, groupId, testAccount }, use) => { // Create CLI page - it will authenticate using the created user credentials // and have access to the created group - const cliPage = new TestCliPage(); + const cliPage = new TestCliPage(page); // Store test info for CLI tests to use cliPage.testGroupName = groupName; diff --git a/integration-tests/tests/fixtures/console-warnings.fixture.ts b/integration-tests/tests/fixtures/console-warnings.fixture.ts index 7710258026..b9e3b6e2c4 100644 --- a/integration-tests/tests/fixtures/console-warnings.fixture.ts +++ b/integration-tests/tests/fixtures/console-warnings.fixture.ts @@ -11,6 +11,8 @@ export const test = base.extend({ 'Form submission canceled because the form is not connected', 'ERR_INCOMPLETE_CHUNKED_ENCODING', "Response to preflight request doesn't pass access control check", // LAPIS sometimes hangs up preflight requests for unknown reasons + 'Failed to load resource: the server responded with a status of 401', // Authelia returns 401 on a probe login attempt before tryLoginOrRegister falls back to registration + 'AxiosError: Request failed with status code 401', // Same 401 surfaced from Authelia's SPA axios wrapper ]; const isHarmless = harmlessMessages.some((harmless) => diff --git a/integration-tests/tests/pages/CliPage.ts b/integration-tests/tests/pages/CliPage.ts index 9cc330b3af..8d5d015fa3 100644 --- a/integration-tests/tests/pages/CliPage.ts +++ b/integration-tests/tests/pages/CliPage.ts @@ -1,12 +1,15 @@ -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { promisify, stripVTControlCharacters } from 'util'; import { writeFile, unlink, rm } from 'fs/promises'; -import { join } from 'path'; +import { delimiter, join, resolve } from 'path'; import { tmpdir } from 'os'; -import { test } from '@playwright/test'; +import { Page, test } from '@playwright/test'; import { randomUUID } from 'crypto'; +import { AuthPage } from './auth.page'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); +const LOCAL_TEST_DOMAIN_SUFFIX = '.loculus.test'; export interface CliResult { stdout: string; @@ -22,8 +25,10 @@ export class CliPage { private keyringService: string; private configFile: string; private dataHome: string; + private localCliSource: string; + private keyringPython?: string; - constructor() { + constructor(private page?: Page) { const uuid = randomUUID(); // Get base URL from environment or default to localhost this.baseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:3000'; @@ -36,23 +41,15 @@ export class CliPage { // XDG_DATA_HOME. Without isolation, parallel tests perform concurrent read-modify-write // on that file, causing race conditions that silently clobber each other's tokens. this.dataHome = join(tmpdir(), `loculus-cli-test-data-${uuid}`); + this.localCliSource = resolve(__dirname, '../../..', 'cli/src'); } - /** - * Execute a CLI command with the given arguments - */ - async execute( - args: string[], - options?: { - cwd?: string; - env?: Record; - timeout?: number; - }, - ): Promise { - const { cwd, env = {}, timeout = 30000 } = options || {}; + private commandEnv(env: Record = {}): NodeJS.ProcessEnv { + const pythonPath = [this.localCliSource, env.PYTHONPATH ?? process.env.PYTHONPATH] + .filter(Boolean) + .join(delimiter); - // Set up environment variables - const cmdEnv = { + return { ...process.env, ...env, // Extract instance from base URL @@ -63,10 +60,31 @@ export class CliPage { LOCULUS_CONFIG: this.configFile, // Use unique data directory so each test gets its own keyring file XDG_DATA_HOME: this.dataHome, + // Route .loculus.test through localhost for spawned Python CLI processes. + LOCULUS_CLI_LOCAL_TEST_DNS: '1', + LOCULUS_CLI_ALLOW_INSECURE_LOCAL_TEST_TLS: '1', + // Run the CLI from this checkout so integration tests exercise local edits. + PYTHONPATH: pythonPath, // Disable interactive features like spinners CI: 'true', NO_COLOR: '1', }; + } + + /** + * Execute a CLI command with the given arguments + */ + async execute( + args: string[], + options?: { + cwd?: string; + env?: Record; + timeout?: number; + }, + ): Promise { + const { cwd, env = {}, timeout = 30000 } = options || {}; + + const cmdEnv = this.commandEnv(env); const command = `loculus ${args.join(' ')}`; const timestamp = new Date().toISOString(); @@ -271,9 +289,200 @@ export class CliPage { * Login with username and password */ async login(username: string, password: string): Promise { + if (this.page) { + return this.loginWithBrowserToken(username, password); + } return this.execute(['auth', 'login', '--username', username, '--password', password]); } + private async loginWithBrowserToken(username: string, password: string): Promise { + const page = this.page; + if (!page) { + return this.execute(['auth', 'login', '--username', username, '--password', password]); + } + const timestamp = new Date().toISOString(); + const startTime = Date.now(); + + try { + await page.context().clearCookies(); + const authPage = new AuthPage(page); + const loggedIn = await authPage.login(username, password); + if (!loggedIn) { + return this.cliResult({ + exitCode: 1, + stdout: '', + stderr: 'Invalid username or password', + command: 'browser-backed loculus auth login', + timestamp, + startTime, + }); + } + + const cookies = await page.context().cookies(this.baseUrl); + const accessToken = cookies.find((cookie) => cookie.name === 'access_token')?.value; + if (!accessToken) { + throw new Error('Browser login did not produce an access_token cookie'); + } + const tokenUsername = this.usernameFromToken(accessToken); + if (tokenUsername !== username) { + return this.cliResult({ + exitCode: 1, + stdout: '', + stderr: 'Invalid username or password', + command: 'browser-backed loculus auth login', + timestamp, + startTime, + }); + } + + await this.seedToken(username, accessToken); + return this.cliResult({ + exitCode: 0, + stdout: `✓ Successfully logged in as ${username}`, + stderr: '', + command: 'browser-backed loculus auth login', + timestamp, + startTime, + }); + } catch (error) { + return this.cliResult({ + exitCode: 1, + stdout: '', + stderr: error instanceof Error ? error.message : String(error), + command: 'browser-backed loculus auth login', + timestamp, + startTime, + }); + } + } + + private async seedToken(username: string, accessToken: string): Promise { + const instanceInfo = await this.fetchInstanceInfo(); + const autheliaUrl = instanceInfo.hosts.authelia; + const script = ` +import json +import os +import time + +import keyring + +service = os.environ["LOCULUS_CLI_KEYRING_SERVICE"] +username = os.environ["LOCULUS_CLI_SEED_USERNAME"] +authelia_url = os.environ["LOCULUS_CLI_SEED_AUTHELIA_URL"] +access_token = os.environ["LOCULUS_CLI_SEED_ACCESS_TOKEN"] + +token_info = { + "access_token": access_token, + "refresh_token": None, + "expires_in": 3600, + "refresh_expires_in": 0, + "token_type": "Bearer", + "id_token": access_token, + "subject": username, + "created_at": time.time(), +} + +keyring.set_password(service, f"{authelia_url}#{username}", json.dumps(token_info)) +keyring.set_password(service, "current_user", username) +`; + + await execFileAsync(await this.getKeyringPython(), ['-c', script], { + env: this.commandEnv({ + LOCULUS_CLI_SEED_USERNAME: username, + LOCULUS_CLI_SEED_AUTHELIA_URL: autheliaUrl, + LOCULUS_CLI_SEED_ACCESS_TOKEN: accessToken, + }), + timeout: 10000, + }); + } + + private async getKeyringPython(): Promise { + if (this.keyringPython) { + return this.keyringPython; + } + + const candidates = [ + process.env.LOCULUS_CLI_KEYRING_PYTHON, + 'python3', + '/usr/bin/python3', + ].filter((candidate): candidate is string => Boolean(candidate)); + + const errors: string[] = []; + for (const candidate of candidates) { + try { + await execFileAsync(candidate, ['-c', 'import keyring'], { + env: this.commandEnv(), + timeout: 10000, + }); + this.keyringPython = candidate; + return candidate; + } catch (error: unknown) { + const execError = error as { stderr?: string; message?: string }; + errors.push( + `${candidate}: ${execError.stderr?.trim() || execError.message || 'failed'}`, + ); + } + } + + throw new Error(`No Python interpreter with keyring is available (${errors.join('; ')})`); + } + + private async fetchInstanceInfo(): Promise<{ hosts: { authelia: string } }> { + const url = new URL('/loculus-info', this.baseUrl); + const headers: Record = {}; + + if (url.hostname === 'loculus.test' || url.hostname.endsWith(LOCAL_TEST_DOMAIN_SUFFIX)) { + headers.Host = url.host; + url.hostname = '127.0.0.1'; + } + + if (!this.page) { + throw new Error('Browser-backed CLI login requires a Playwright page'); + } + + const response = await this.page.request.get(url.toString(), { headers }); + if (!response.ok()) { + throw new Error(`Failed to fetch instance info: HTTP ${response.status()}`); + } + return (await response.json()) as { hosts: { authelia: string } }; + } + + private usernameFromToken(token: string): string | undefined { + const [, payload] = token.split('.'); + if (!payload) { + return undefined; + } + const paddedPayload = payload.padEnd( + payload.length + ((4 - (payload.length % 4)) % 4), + '=', + ); + const claims = JSON.parse(Buffer.from(paddedPayload, 'base64url').toString('utf8')) as { + preferred_username?: string; + sub?: string; + }; + return claims.preferred_username ?? claims.sub; + } + + private cliResult(input: { + exitCode: number; + stdout: string; + stderr: string; + command: string; + timestamp: string; + startTime: number; + }): CliResult { + const result = { + stdout: stripVTControlCharacters(input.stdout.trim()), + stderr: stripVTControlCharacters(input.stderr.trim()), + exitCode: input.exitCode, + command: input.command, + timestamp: input.timestamp, + duration: Date.now() - input.startTime, + }; + this.attachToTest(result); + return result; + } + /** * Check authentication status */ diff --git a/integration-tests/tests/pages/auth.page.ts b/integration-tests/tests/pages/auth.page.ts index 1e63d0964c..4db536ddee 100644 --- a/integration-tests/tests/pages/auth.page.ts +++ b/integration-tests/tests/pages/auth.page.ts @@ -1,62 +1,93 @@ import { Page, expect } from '@playwright/test'; import { TestAccount } from '../types/auth.types'; +// Login flows now hit Authelia and registration goes through the dedicated +// registration-service. Selectors target the data-testids exposed by each +// surface so they don't break when the visual layout changes. + export class AuthPage { constructor(private page: Page) {} async navigateToRegister() { - await this.page.goto('/'); - await this.page.getByRole('link', { name: 'Login' }).click(); - await this.page.getByRole('link', { name: 'Register' }).click(); + // Go directly to the registration service host. Authelia itself doesn't + // surface a register link by default; in production deployments the + // operator advertises the registration URL elsewhere. + const registrationUrl = + process.env.LOCULUS_REGISTRATION_URL || 'https://register.loculus.test:8443/'; + await this.page.goto(registrationUrl); + await expect(this.page.getByTestId('register-form')).toBeVisible(); } - async createAccount(account: TestAccount) { + async createAccount(account: TestAccount, options: { loginAfterCreate?: boolean } = {}) { await this.navigateToRegister(); - await this.page.getByLabel('Username').click(); - await this.page.getByLabel('Username').fill(account.username); - - await this.page.getByLabel('Password', { exact: true }).fill(account.password); - await this.page.getByLabel('Password', { exact: true }).press('Tab'); - - await this.page.getByLabel('Confirm password').fill(account.password); - await this.page.getByLabel('Confirm password').press('Tab'); - - await this.page.getByLabel('Email').fill(account.email); - await this.page.getByLabel('Email').press('Tab'); - - await this.page.getByLabel('First name').fill(account.firstName); - await this.page.getByLabel('First name').press('Tab'); - - await this.page.getByLabel('Last name').fill(account.lastName); - await this.page.getByLabel('Last name').press('Tab'); - - await this.page.getByLabel('University / Organisation').fill(account.organization); + await this.page.getByTestId('username').fill(account.username); + await this.page.getByTestId('email').fill(account.email); + await this.page.getByTestId('first-name').fill(account.firstName); + await this.page.getByTestId('last-name').fill(account.lastName); + await this.page.getByTestId('organization').fill(account.organization); + await this.page.getByTestId('password').fill(account.password); + await this.page.getByTestId('confirm-password').fill(account.password); + await this.page.getByTestId('accept-terms').check(); + await Promise.all([ + this.page.waitForURL(/registered=1/, { timeout: 15_000 }), + this.page.getByTestId('register-submit').click(), + ]); - await this.page.getByLabel('I agree').check(); - await this.page.getByRole('button', { name: 'Register' }).click(); + if (options.loginAfterCreate ?? true) { + expect(await this.login(account.username, account.password)).toBe(true); + } } async login(username: string, password: string): Promise { await this.page.goto('/'); + if (await this.page.getByRole('link', { name: 'My account' }).isVisible()) { + return true; + } + await this.page.getByRole('link', { name: 'Login' }).click(); - await this.page.getByLabel('Username').fill(username); - await this.page.getByLabel('Password', { exact: true }).fill(password); - await this.page.getByRole('button', { name: 'Sign in' }).click(); + const usernameField = this.page.getByRole('textbox', { name: /username/i }); + const accountLink = this.page.getByRole('link', { name: 'My account' }); + const consent = this.page.getByRole('button', { name: /^accept$/i }); + const nextStep = await Promise.race([ + usernameField.waitFor({ state: 'visible', timeout: 10_000 }).then(() => 'form'), + consent.waitFor({ state: 'visible', timeout: 10_000 }).then(() => 'consent'), + accountLink.waitFor({ state: 'visible', timeout: 10_000 }).then(() => 'done'), + ]).catch(() => 'form'); + + if (nextStep === 'done') { + return true; + } - const successSelector = this.page.waitForSelector('text=Welcome to Loculus', { - state: 'attached', - }); - const failureSelector = this.page.waitForSelector('text=Invalid username or password', { - state: 'attached', - }); + if (nextStep === 'consent') { + await consent.click(); + await accountLink.waitFor({ state: 'visible', timeout: 30_000 }); + return true; + } - const result = await Promise.race([ - successSelector.then(() => true), - failureSelector.then(() => false), - ]); + // Authelia login form — use roles to avoid matching the toggle-visibility + // button that also has "password" in its aria-label. + await usernameField.fill(username); + await this.page.getByRole('textbox', { name: /^password$/i }).fill(password); + await this.page.getByRole('button', { name: /sign in|log in/i }).click(); + + // Authelia shows an OIDC consent screen for the website on first login; + // accept it if present. We don't gate on it so subsequent logins where + // consent is remembered work unchanged. + await consent.click({ timeout: 5000 }).catch(() => {}); - return result; + const success = this.page.getByRole('link', { name: 'My account' }).waitFor({ + state: 'visible', + timeout: 30_000, + }); + const failure = this.page + .getByText(/incorrect username or password|invalid|authentication failed/i) + .first() + .waitFor({ state: 'attached', timeout: 30_000 }); + + return Promise.race([success.then(() => true), failure.then(() => false)]).catch( + () => false, + ); } async tryLoginOrRegister(account: TestAccount) { @@ -69,8 +100,10 @@ export class AuthPage { async logout() { await this.page.goto('/'); await this.page.getByRole('link', { name: 'My account' }).click(); - await this.page.getByRole('link', { name: 'Logout' }).click(); - await this.page.getByRole('button', { name: 'Logout' }).click(); + await Promise.all([ + this.page.waitForURL(/\/logout$/), + this.page.getByRole('link', { name: 'Logout' }).click(), + ]); await expect(this.page.getByText('You have been logged out')).toBeVisible(); } } diff --git a/integration-tests/tests/pages/my-account.page.ts b/integration-tests/tests/pages/my-account.page.ts index 3c57c7302c..3f84c4688e 100644 --- a/integration-tests/tests/pages/my-account.page.ts +++ b/integration-tests/tests/pages/my-account.page.ts @@ -17,16 +17,17 @@ export class MyAccountPage { const link = this.getEditAccountInformationLink(); await expect(link).toBeVisible(); await expect(link).toHaveAttribute('target', '_blank'); - await expect(link).toHaveAttribute('href', /\/realms\/loculus\/account$/); + // Authelia hosts its account portal at the auth root. + await expect(link).toHaveAttribute('href', /authentication.*/); } - async clickEditAccountAndGetKeycloakPage() { + async clickEditAccountAndGetAccountPage() { const link = this.getEditAccountInformationLink(); const popupPromise = this.page.waitForEvent('popup'); await link.click(); - const keycloakPage = await popupPromise; - await keycloakPage.waitForLoadState(); - return keycloakPage; + const accountPage = await popupPromise; + await accountPage.waitForLoadState(); + return accountPage; } private groupListItem(groupName: string) { diff --git a/integration-tests/tests/pages/review.page.ts b/integration-tests/tests/pages/review.page.ts index 3d64ee8cc5..275e3e2b5f 100644 --- a/integration-tests/tests/pages/review.page.ts +++ b/integration-tests/tests/pages/review.page.ts @@ -152,7 +152,8 @@ export class ReviewPage { } async goToReleasedSequences(): Promise { - await this.page.getByRole('link', { name: 'released sequences' }).click(); + const currentUrl = new URL(this.page.url()); + await this.page.goto(currentUrl.pathname.replace(/\/review$/, '/released')); await expect(this.page).toHaveURL((url) => url.pathname.endsWith('/released')); return new SearchPage(this.page); } diff --git a/integration-tests/tests/specs/auth/edit-account.spec.ts b/integration-tests/tests/specs/auth/edit-account.spec.ts index f7330ff4d9..ff5dce233c 100644 --- a/integration-tests/tests/specs/auth/edit-account.spec.ts +++ b/integration-tests/tests/specs/auth/edit-account.spec.ts @@ -10,7 +10,7 @@ test.describe('Test redirect to Edit Account page', () => { await myAccountPage.expectEditAccountLinkHasCorrectHref(); }); - test('Edit account information opens Keycloak account management page', async ({ + test('Edit account information opens the Authelia account page', async ({ page, authenticatedUser, }) => { @@ -18,8 +18,8 @@ test.describe('Test redirect to Edit Account page', () => { const myAccountPage = new MyAccountPage(page); await myAccountPage.goto(); - const keycloakPage = await myAccountPage.clickEditAccountAndGetKeycloakPage(); - await expect(keycloakPage).toHaveTitle('Keycloak Account Management'); - await keycloakPage.close(); + const accountPage = await myAccountPage.clickEditAccountAndGetAccountPage(); + await expect(accountPage).toHaveTitle(/Authelia|Account/); + await accountPage.close(); }); }); diff --git a/integration-tests/tests/specs/backend/authentication.spec.ts b/integration-tests/tests/specs/backend/authentication.spec.ts index 1f6124616c..e7a3a0bc41 100644 --- a/integration-tests/tests/specs/backend/authentication.spec.ts +++ b/integration-tests/tests/specs/backend/authentication.spec.ts @@ -36,7 +36,12 @@ function getBackendBaseUrl(): URL { const baseUrl = new URL(process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'http://localhost:3000'); const hostname = baseUrl.hostname; - if (hostname === 'localhost' || hostname === '127.0.0.1') { + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === 'loculus.test' || + hostname.endsWith('.loculus.test') + ) { const protocol = baseUrl.protocol === 'https:' ? 'https:' : 'http:'; return new URL(`${protocol}//localhost:8079`); } @@ -46,7 +51,7 @@ function getBackendBaseUrl(): URL { } test.describe('Backend authentication', () => { - test('rejects tokens that were not signed by Keycloak', async ({ backendRequest }) => { + test('rejects tokens that were not signed by the IDP', async ({ backendRequest }) => { const response = await backendRequest.get('/dummy-organism/get-data-to-edit/1/1', { headers: { Authorization: `Bearer ${tokenSignedWithDifferentKey}`, diff --git a/integration-tests/tests/specs/features/sequence-fasta.dependent.spec.ts b/integration-tests/tests/specs/features/sequence-fasta.dependent.spec.ts index 5557407e76..d77baae671 100644 --- a/integration-tests/tests/specs/features/sequence-fasta.dependent.spec.ts +++ b/integration-tests/tests/specs/features/sequence-fasta.dependent.spec.ts @@ -1,6 +1,7 @@ import { expect } from '@playwright/test'; import { test } from '../../fixtures/console-warnings.fixture'; import { SearchPage } from '../../pages/search.page'; +import { getWithLocalTestDns } from '../../utils/link-helpers'; test.describe('Sequence FASTA endpoint', () => { test('returns valid FASTA with CORS headers', async ({ page, baseURL, request }) => { @@ -9,7 +10,10 @@ test.describe('Sequence FASTA endpoint', () => { const accessionVersions = await searchPage.waitForSequencesInSearch(1); const { accessionVersion } = accessionVersions[0]; - const response = await request.get(`${baseURL}/seq/${accessionVersion}.fa`); + const response = await getWithLocalTestDns( + request, + `${baseURL}/seq/${accessionVersion}.fa`, + ); expect(response.ok()).toBe(true); expect(response.headers()['access-control-allow-origin']).toBe('*'); @@ -30,7 +34,10 @@ test.describe('Sequence FASTA endpoint', () => { const accessionVersions = await searchPage.waitForSequencesInSearch(1); const { accessionVersion } = accessionVersions[0]; - const response = await request.get(`${baseURL}/seq/${accessionVersion}.fa?download=true`); + const response = await getWithLocalTestDns( + request, + `${baseURL}/seq/${accessionVersion}.fa?download=true`, + ); expect(response.ok()).toBe(true); expect(response.headers()['access-control-allow-origin']).toBe('*'); diff --git a/integration-tests/tests/specs/features/submission-login-required.spec.ts b/integration-tests/tests/specs/features/submission-login-required.spec.ts index b56dc0b035..5121950902 100644 --- a/integration-tests/tests/specs/features/submission-login-required.spec.ts +++ b/integration-tests/tests/specs/features/submission-login-required.spec.ts @@ -15,6 +15,6 @@ test.describe('Submission page login requirements', () => { await expect(loginLink).toBeVisible(); await loginLink.click(); - await expect(page).toHaveURL(/realms\/loculus/); + await expect(page).toHaveURL(/authentication\.[^/]+.*flow=openid_connect/); }); }); diff --git a/integration-tests/tests/utils/link-helpers.ts b/integration-tests/tests/utils/link-helpers.ts index 52f7185903..601836fed4 100644 --- a/integration-tests/tests/utils/link-helpers.ts +++ b/integration-tests/tests/utils/link-helpers.ts @@ -1,4 +1,67 @@ -import { Locator, expect } from '@playwright/test'; +import { APIRequestContext, Locator, Page, expect } from '@playwright/test'; + +const LOCAL_TEST_DOMAIN_SUFFIX = '.loculus.test'; + +function isLocalTestDomain(hostname: string) { + return hostname === 'loculus.test' || hostname.endsWith(LOCAL_TEST_DOMAIN_SUFFIX); +} + +function requestTargetForLocalTestDns(urlString: string, cookieHeader?: string) { + const url = new URL(urlString); + + if (isLocalTestDomain(url.hostname)) { + const host = url.host; + const headers: Record = { Host: host }; + url.hostname = '127.0.0.1'; + if (cookieHeader) { + headers.Cookie = cookieHeader; + } + return { + url: url.toString(), + headers, + }; + } + + return { url: urlString }; +} + +async function requestTargetForPageLocalTestDns(page: Page, urlString: string) { + const cookies = await page.context().cookies(urlString); + const cookieHeader = cookies.map(({ name, value }) => `${name}=${value}`).join('; '); + return requestTargetForLocalTestDns(urlString, cookieHeader); +} + +export async function getWithLocalTestDns(request: APIRequestContext, url: string) { + const requestTarget = requestTargetForLocalTestDns(url); + return request.get(requestTarget.url, { + headers: requestTarget.headers, + }); +} + +async function getFollowingLocalTestRedirects(page: Page, initialUrl: string) { + let nextUrl = initialUrl; + + for (let redirectCount = 0; redirectCount < 10; redirectCount++) { + const requestTarget = await requestTargetForPageLocalTestDns(page, nextUrl); + const response = await page.request.get(requestTarget.url, { + headers: requestTarget.headers, + maxRedirects: 0, + }); + + if (![301, 302, 303, 307, 308].includes(response.status())) { + return response; + } + + const location = response.headers().location; + if (!location) { + return response; + } + + nextUrl = new URL(location, nextUrl).toString(); + } + + throw new Error(`Too many redirects while fetching ${initialUrl}`); +} /** * Fetches content from a link's href attribute and asserts it matches expected content @@ -14,7 +77,7 @@ export async function getFromLinkTargetAndAssertContent( throw new Error(`Link locator has no href attribute`); } const url = href.startsWith('http') ? href : new URL(href, page.url()).toString(); - const response = await page.request.get(url); + const response = await getFollowingLocalTestRedirects(page, url); expect(response.status()).toBe(200); const content = await response.text(); expect(content).toBe(expectedContent); diff --git a/keycloak/keycloakify/.dockerignore b/keycloak/keycloakify/.dockerignore deleted file mode 100644 index 85da21a392..0000000000 --- a/keycloak/keycloakify/.dockerignore +++ /dev/null @@ -1,67 +0,0 @@ -.storybook -node_modules -README.md -dist -dist_keycloak -.devcontainer -.gitignore -Dockerfile -.dockerignore - -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules -jspm_packages - -# Optional npm cache directory -.npm - -# yarn cache directory -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - - -# Optional REPL history -.node_repl_history - -.vscode - -.DS_Store - -/dist - -/dist_keycloak -/build -/storybook-static \ No newline at end of file diff --git a/keycloak/keycloakify/.eslintrc.cjs b/keycloak/keycloakify/.eslintrc.cjs deleted file mode 100644 index 1dc4447eb1..0000000000 --- a/keycloak/keycloakify/.eslintrc.cjs +++ /dev/null @@ -1,27 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended", - "plugin:storybook/recommended" - ], - ignorePatterns: ["dist", ".eslintrc.cjs"], - parser: "@typescript-eslint/parser", - plugins: ["react-refresh"], - rules: { - "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], - "react-hooks/exhaustive-deps": "off", - "@typescript-eslint/no-redeclare": "off", - "no-labels": "off" - }, - overrides: [ - { - files: ["**/*.stories.*"], - rules: { - "import/no-anonymous-default-export": "off" - } - } - ] -}; diff --git a/keycloak/keycloakify/.gitignore b/keycloak/keycloakify/.gitignore deleted file mode 100644 index ca0cce963f..0000000000 --- a/keycloak/keycloakify/.gitignore +++ /dev/null @@ -1,57 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules -jspm_packages - -# Optional npm cache directory -.npm - -# yarn cache directory -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - - -# Optional REPL history -.node_repl_history - -.vscode - -.DS_Store - -/dist - -/dist_keycloak -/build -/storybook-static diff --git a/keycloak/keycloakify/.nvmrc b/keycloak/keycloakify/.nvmrc deleted file mode 100644 index 92f279e3e6..0000000000 --- a/keycloak/keycloakify/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v22 \ No newline at end of file diff --git a/keycloak/keycloakify/.prettierignore b/keycloak/keycloakify/.prettierignore deleted file mode 100644 index 770d01c0ef..0000000000 --- a/keycloak/keycloakify/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -/dist/ -/dist_keycloak/ -/public/keycloakify-dev-resources/ -/.vscode/ -/.yarn_home/ \ No newline at end of file diff --git a/keycloak/keycloakify/.prettierrc.json b/keycloak/keycloakify/.prettierrc.json deleted file mode 100644 index 6281138ed8..0000000000 --- a/keycloak/keycloakify/.prettierrc.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "printWidth": 90, - "tabWidth": 4, - "useTabs": false, - "semi": true, - "singleQuote": false, - "trailingComma": "none", - "bracketSpacing": true, - "arrowParens": "avoid", - "overrides": [ - { - "files": [ - "**/login/pages/*.tsx", - "**/account/pages/*.tsx", - "**/login/Template.tsx", - "**/account/Template.tsx", - "**/login/UserProfileFormFields.tsx", - "KcApp.tsx" - ], - "options": { - "printWidth": 150 - } - } - ] -} diff --git a/keycloak/keycloakify/.storybook/main.ts b/keycloak/keycloakify/.storybook/main.ts deleted file mode 100644 index dc266a332e..0000000000 --- a/keycloak/keycloakify/.storybook/main.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { StorybookConfig } from "@storybook/react-vite"; - -const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], - framework: { - name: "@storybook/react-vite", - options: {} - }, - - staticDirs: ["../public"], - - docs: {}, - - typescript: { - reactDocgen: "react-docgen-typescript" - } -}; -export default config; diff --git a/keycloak/keycloakify/.storybook/preview-head.html b/keycloak/keycloakify/.storybook/preview-head.html deleted file mode 100644 index da831e5b62..0000000000 --- a/keycloak/keycloakify/.storybook/preview-head.html +++ /dev/null @@ -1,25 +0,0 @@ - diff --git a/keycloak/keycloakify/.storybook/preview.ts b/keycloak/keycloakify/.storybook/preview.ts deleted file mode 100644 index 7dcc1a4cc0..0000000000 --- a/keycloak/keycloakify/.storybook/preview.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Preview } from "@storybook/react"; - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i - } - } - }, - - tags: ["autodocs"] -}; - -export default preview; diff --git a/keycloak/keycloakify/.yarnrc.yml b/keycloak/keycloakify/.yarnrc.yml deleted file mode 100644 index 3186f3f079..0000000000 --- a/keycloak/keycloakify/.yarnrc.yml +++ /dev/null @@ -1 +0,0 @@ -nodeLinker: node-modules diff --git a/keycloak/keycloakify/Dockerfile b/keycloak/keycloakify/Dockerfile deleted file mode 100644 index 7ca1eaefea..0000000000 --- a/keycloak/keycloakify/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -ARG NODE_VERSION=22 -FROM node:${NODE_VERSION}-bookworm AS builder - -ARG KEYCLOAK_ORCID_VERSION=1.3.0 -ARG KEYCLOAK_MAJOR_VERSION=24 - -USER root - -RUN apt-get update && apt-get install -y maven - -RUN mvn --version - -WORKDIR /app -RUN wget https://github.com/eosc-kc/keycloak-orcid/releases/download/${KEYCLOAK_ORCID_VERSION}/keycloak-orcid-${KEYCLOAK_ORCID_VERSION}.jar -COPY package.json yarn.lock .yarnrc.yml ./ -RUN corepack enable -RUN corepack install -RUN yarn install --immutable && \ - yarn cache clean -COPY . . -RUN yarn build-keycloak-theme - -RUN if [ "$KEYCLOAK_MAJOR_VERSION" -le 25 ]; then \ - mv dist_keycloak/keycloak-theme-for-kc-22-to-25.jar loculus-theme.jar; \ - else \ - mv dist_keycloak/keycloak-theme-for-kc-all-other-versions.jar loculus-theme.jar; \ - fi -# You can set an explicit version in vite.config.ts, see docs here: -# https://docs.keycloakify.dev/targeting-specific-keycloak-versions -# But for now this was the easiest way; In the future once we migrated away from KC<25 we can just get rid if this entirely - -FROM alpine:3.23 -RUN mkdir /output -COPY --from=builder /app/keycloak-orcid*.jar /output/ -COPY --from=builder /app/loculus-theme.jar /output/ -RUN ls -alht /output -CMD sh -c 'cp /output/*.jar /destination/' \ No newline at end of file diff --git a/keycloak/keycloakify/LICENSE b/keycloak/keycloakify/LICENSE deleted file mode 100644 index 5e0b3326c7..0000000000 --- a/keycloak/keycloakify/LICENSE +++ /dev/null @@ -1,23 +0,0 @@ -MIT License - -This license applies specifically to this directory - -Copyright (c) 2020 GitHub user u/garronej - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/keycloak/keycloakify/README.md b/keycloak/keycloakify/README.md deleted file mode 100644 index 535227fa7f..0000000000 --- a/keycloak/keycloakify/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Loculus Keycloakify theme - -This theme is primarily used for: - -- Adding ORCID support -- Adding a little tickbox to the registration process, as well as the IDP review page (after registering with ORCID). -- Minimal styling changes. -- Overriding the realm name in various places. - -Changes are deliberately kept minimal to make it easier to maintain the theme. - -Based on upstream commit: https://github.com/keycloakify/keycloakify-starter/commit/a543bc0f73e5874648cf6d907c88aba9b4b48536 - -## Quick start - -First, ensure [nvm](https://github.com/nvm-sh/nvm) is installed and that corepack is enabled (`corepack enable`). -If you don't have the version of npm specified in `.nvmrc` installed, do so by running `nvm install` in this directory. -Then, run the following commands: -```bash -nvm use -corepack install -yarn install --immutable -``` - -If you get: - -```log -error This project's package.json defines "packageManager": "yarn@4.5.1". However the current global version of Yarn is 1.22.22. -``` - -then uninstall the global yarn version (e.g. `brew uninstall yarn`) and try `yarn install` again. - -## Testing the theme locally - -### Storybook - -For a quick preview of the theme, you can use Storybook: - -```bash -yarn storybook -``` - -Then visit http://localhost:6006/ - -### Use with actual dev Keycloak - -Not so useful right now as it doesn't show the right pages yet: - -```sh -npx keycloakify start-keycloak -``` - -(needs port 8080 to be available, so shut down your cluster if you have one running) - -Then visit https://my-theme.keycloakify.dev (ensure ad blocker is disabled if you get an error). - -[Documentation](https://docs.keycloakify.dev/testing-your-theme) - -## How to customize the theme - -[Documentation](https://docs.keycloakify.dev/customization-strategies) - -## Building the theme - -You need to have [Maven](https://maven.apache.org/) installed to build the theme (Maven >= 3.1.1, Java >= 7). -The `mvn` command must be in the $PATH. - -- macOS: `brew install maven` -- On Debian/Ubuntu: `sudo apt-get install maven` -- On Windows: `choco install openjdk` and `choco install maven` (Or download from [here](https://maven.apache.org/download.cgi)) - -```bash -npm run build-keycloak-theme -``` - -Note that by default Keycloakify generates multiple .jar files for different versions of Keycloak. -You can customize this behavior, see documentation [here](https://docs.keycloakify.dev/targeting-specific-keycloak-versions). diff --git a/keycloak/keycloakify/index.html b/keycloak/keycloakify/index.html deleted file mode 100644 index 4d1d82921f..0000000000 --- a/keycloak/keycloakify/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - -
- - - diff --git a/keycloak/keycloakify/package.json b/keycloak/keycloakify/package.json deleted file mode 100644 index 532956dad3..0000000000 --- a/keycloak/keycloakify/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "loculus-keycloak-theme", - "version": "0.0.1", - "description": "Adapted from keycloakify-starter", - "repository": { - "type": "git", - "url": "git://github.com/loculus-project/loculus.git" - }, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "build-keycloak-theme": "npm run build && keycloakify build", - "storybook": "storybook dev -p 6006", - "storybook-build": "storybook build", - "storybook-upgrade": "storybook upgrade", - "format": "prettier . --write" - }, - "license": "MIT", - "keywords": [], - "dependencies": { - "keycloakify": "^11.15.3", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@storybook/react": "^8.4.6", - "@storybook/react-vite": "^10.3.6", - "@types/react": "^18.3.13", - "@types/react-dom": "^18.3.1", - "@typescript-eslint/eslint-plugin": "^8.59.1", - "@typescript-eslint/parser": "^8.59.1", - "@vitejs/plugin-react": "^5.1.4", - "eslint": "^10.3.0", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "eslint-plugin-storybook": "^10.3.6", - "prettier": "3.8.3", - "storybook": "^10.3.6", - "typescript": "^5.2.2", - "vite": "^8.0.10" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "packageManager": "yarn@4.5.3" -} diff --git a/keycloak/keycloakify/public/favicon.svg b/keycloak/keycloakify/public/favicon.svg deleted file mode 100644 index 85174db8af..0000000000 --- a/keycloak/keycloakify/public/favicon.svg +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/keycloak/keycloakify/src/email/html/email-test.ftl b/keycloak/keycloakify/src/email/html/email-test.ftl deleted file mode 100644 index 80c2436f87..0000000000 --- a/keycloak/keycloakify/src/email/html/email-test.ftl +++ /dev/null @@ -1,4 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.emailLayout> -${kcSanitize(msg("emailTestBodyHtml",properties.projectName))?no_esc} - diff --git a/keycloak/keycloakify/src/email/html/email-update-confirmation.ftl b/keycloak/keycloakify/src/email/html/email-update-confirmation.ftl deleted file mode 100644 index a2402c6744..0000000000 --- a/keycloak/keycloakify/src/email/html/email-update-confirmation.ftl +++ /dev/null @@ -1,4 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.emailLayout> -${kcSanitize(msg("emailUpdateConfirmationBodyHtml",link, newEmail, properties.projectName, linkExpirationFormatter(linkExpiration)))?no_esc} - diff --git a/keycloak/keycloakify/src/email/html/email-verification.ftl b/keycloak/keycloakify/src/email/html/email-verification.ftl deleted file mode 100644 index b2d4ed789a..0000000000 --- a/keycloak/keycloakify/src/email/html/email-verification.ftl +++ /dev/null @@ -1,4 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.emailLayout> -${kcSanitize(msg("emailVerificationBodyHtml",link, linkExpiration, properties.projectName, linkExpirationFormatter(linkExpiration)))?no_esc} - diff --git a/keycloak/keycloakify/src/email/html/executeActions.ftl b/keycloak/keycloakify/src/email/html/executeActions.ftl deleted file mode 100644 index b5acd60307..0000000000 --- a/keycloak/keycloakify/src/email/html/executeActions.ftl +++ /dev/null @@ -1,8 +0,0 @@ -<#outputformat "plainText"> -<#assign requiredActionsText><#if requiredActions??><#list requiredActions><#items as reqActionItem>${msg("requiredAction.${reqActionItem}")}<#sep>, - - -<#import "template.ftl" as layout> -<@layout.emailLayout> -${kcSanitize(msg("executeActionsBodyHtml",link, linkExpiration, properties.projectName, requiredActionsText, linkExpirationFormatter(linkExpiration)))?no_esc} - diff --git a/keycloak/keycloakify/src/email/html/identity-provider-link.ftl b/keycloak/keycloakify/src/email/html/identity-provider-link.ftl deleted file mode 100644 index 5db564b9c4..0000000000 --- a/keycloak/keycloakify/src/email/html/identity-provider-link.ftl +++ /dev/null @@ -1,4 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.emailLayout> -${kcSanitize(msg("identityProviderLinkBodyHtml", identityProviderDisplayName, properties.projectName, identityProviderContext.username, link, linkExpiration, linkExpirationFormatter(linkExpiration)))?no_esc} - diff --git a/keycloak/keycloakify/src/email/html/org-invite.ftl b/keycloak/keycloakify/src/email/html/org-invite.ftl deleted file mode 100644 index 6eb85fce74..0000000000 --- a/keycloak/keycloakify/src/email/html/org-invite.ftl +++ /dev/null @@ -1,8 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.emailLayout> -<#if firstName?? && lastName??> - ${kcSanitize(msg("orgInviteBodyPersonalizedHtml", link, linkExpiration, properties.projectName, organization.name, linkExpirationFormatter(linkExpiration), firstName, lastName))?no_esc} -<#else> - ${kcSanitize(msg("orgInviteBodyHtml", link, linkExpiration, properties.projectName, organization.name, linkExpirationFormatter(linkExpiration)))?no_esc} - - diff --git a/keycloak/keycloakify/src/email/html/password-reset.ftl b/keycloak/keycloakify/src/email/html/password-reset.ftl deleted file mode 100644 index 994d94ef8f..0000000000 --- a/keycloak/keycloakify/src/email/html/password-reset.ftl +++ /dev/null @@ -1,4 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.emailLayout> -${kcSanitize(msg("passwordResetBodyHtml",link, linkExpiration, properties.projectName, linkExpirationFormatter(linkExpiration)))?no_esc} - diff --git a/keycloak/keycloakify/src/email/text/email-test.ftl b/keycloak/keycloakify/src/email/text/email-test.ftl deleted file mode 100644 index b02ff8c7c5..0000000000 --- a/keycloak/keycloakify/src/email/text/email-test.ftl +++ /dev/null @@ -1,2 +0,0 @@ -<#ftl output_format="plainText"> -${msg("emailTestBody", properties.projectName)} \ No newline at end of file diff --git a/keycloak/keycloakify/src/email/text/email-update-confirmation.ftl b/keycloak/keycloakify/src/email/text/email-update-confirmation.ftl deleted file mode 100644 index 94e403ed0e..0000000000 --- a/keycloak/keycloakify/src/email/text/email-update-confirmation.ftl +++ /dev/null @@ -1,2 +0,0 @@ -<#ftl output_format="plainText"> -${msg("emailUpdateConfirmationBody",link, newEmail, properties.projectName, linkExpirationFormatter(linkExpiration))} diff --git a/keycloak/keycloakify/src/email/text/email-verification.ftl b/keycloak/keycloakify/src/email/text/email-verification.ftl deleted file mode 100644 index 080c1bee33..0000000000 --- a/keycloak/keycloakify/src/email/text/email-verification.ftl +++ /dev/null @@ -1,2 +0,0 @@ -<#ftl output_format="plainText"> -${msg("emailVerificationBody",link, linkExpiration, properties.projectName, linkExpirationFormatter(linkExpiration))} \ No newline at end of file diff --git a/keycloak/keycloakify/src/email/text/executeActions.ftl b/keycloak/keycloakify/src/email/text/executeActions.ftl deleted file mode 100644 index 9df31b1362..0000000000 --- a/keycloak/keycloakify/src/email/text/executeActions.ftl +++ /dev/null @@ -1,4 +0,0 @@ -<#ftl output_format="plainText"> -<#assign requiredActionsText><#if requiredActions??><#list requiredActions><#items as reqActionItem>${msg("requiredAction.${reqActionItem}")}<#sep>, <#else> - -${msg("executeActionsBody",link, linkExpiration, properties.projectName, requiredActionsText, linkExpirationFormatter(linkExpiration))} \ No newline at end of file diff --git a/keycloak/keycloakify/src/email/text/identity-provider-link.ftl b/keycloak/keycloakify/src/email/text/identity-provider-link.ftl deleted file mode 100644 index af0f231462..0000000000 --- a/keycloak/keycloakify/src/email/text/identity-provider-link.ftl +++ /dev/null @@ -1,2 +0,0 @@ -<#ftl output_format="plainText"> -${msg("identityProviderLinkBody", identityProviderDisplayName, properties.projectName, identityProviderContext.username, link, linkExpiration, linkExpirationFormatter(linkExpiration))} \ No newline at end of file diff --git a/keycloak/keycloakify/src/email/text/org-invite.ftl b/keycloak/keycloakify/src/email/text/org-invite.ftl deleted file mode 100644 index a1a919db12..0000000000 --- a/keycloak/keycloakify/src/email/text/org-invite.ftl +++ /dev/null @@ -1,8 +0,0 @@ -<#ftl output_format="plainText"> - -<#if firstName?? && lastName??> - ${kcSanitize(msg("orgInviteBodyPersonalized", link, linkExpiration, properties.projectName, organization.name, linkExpirationFormatter(linkExpiration), firstName, lastName))} -<#else> - ${kcSanitize(msg("orgInviteBody", link, linkExpiration, properties.projectName, organization.name, linkExpirationFormatter(linkExpiration)))} - - diff --git a/keycloak/keycloakify/src/email/text/password-reset.ftl b/keycloak/keycloakify/src/email/text/password-reset.ftl deleted file mode 100644 index ade7ef92d7..0000000000 --- a/keycloak/keycloakify/src/email/text/password-reset.ftl +++ /dev/null @@ -1,2 +0,0 @@ -<#ftl output_format="plainText"> -${msg("passwordResetBody",link, linkExpiration, properties.projectName, linkExpirationFormatter(linkExpiration))} \ No newline at end of file diff --git a/keycloak/keycloakify/src/email/theme.properties b/keycloak/keycloakify/src/email/theme.properties deleted file mode 100644 index 49fb839f32..0000000000 --- a/keycloak/keycloakify/src/email/theme.properties +++ /dev/null @@ -1,3 +0,0 @@ -parent=base -locales=ar,ca,cs,da,de,el,en,es,fa,fr,fi,hu,it,ja,lt,nl,no,pl,pt,pt-BR,ru,sk,sv,th,tr,uk,zh-CN,zh-TW -projectName=${env.PROJECT_NAME:Loculus} diff --git a/keycloak/keycloakify/src/kc.gen.tsx b/keycloak/keycloakify/src/kc.gen.tsx deleted file mode 100644 index 8702a78fbd..0000000000 --- a/keycloak/keycloakify/src/kc.gen.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// This file is auto-generated by the `update-kc-gen` command. Do not edit it manually. -// Hash: 6fdf6464c2745ee10fbeabd0df9eb834ca1ed7575778dccd8830e4feeeec0a01 - -/* eslint-disable */ - -// @ts-nocheck - -// noinspection JSUnusedGlobalSymbols - -import { lazy, Suspense, type ReactNode } from "react"; - -export type ThemeName = "loculus"; - -export const themeNames: ThemeName[] = ["loculus"]; - -export type KcEnvName = "PROJECT_NAME" | "REGISTRATION_TERMS_MESSAGE"; - -export const kcEnvNames: KcEnvName[] = ["PROJECT_NAME", "REGISTRATION_TERMS_MESSAGE"]; - -export const kcEnvDefaults: Record = { - PROJECT_NAME: "Loculus", - REGISTRATION_TERMS_MESSAGE: "" -}; - -/** - * NOTE: Do not import this type except maybe in your entrypoint. - * If you need to import the KcContext import it either from src/login/KcContext.ts or src/account/KcContext.ts. - * Depending on the theme type you are working on. - */ -export type KcContext = import("./login/KcContext").KcContext; - -declare global { - interface Window { - kcContext?: KcContext; - } -} - -export const KcLoginPage = lazy(() => import("./login/KcPage")); - -export function KcPage(props: { kcContext: KcContext; fallback?: ReactNode }) { - const { kcContext, fallback } = props; - return ( - - {(() => { - switch (kcContext.themeType) { - case "login": - return ; - } - })()} - - ); -} diff --git a/keycloak/keycloakify/src/login/KcContext.ts b/keycloak/keycloakify/src/login/KcContext.ts deleted file mode 100644 index 09aedbe13e..0000000000 --- a/keycloak/keycloakify/src/login/KcContext.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-types */ -import type { ExtendKcContext } from "keycloakify/login"; -import type { KcContext as KcContext_base } from "keycloakify/login/KcContext"; -import type { KcEnvName, ThemeName } from "../kc.gen"; - -export type KcContextExtension = { - themeName: ThemeName; - properties: Record & {}; - // NOTE: Here you can declare more properties to extend the KcContext - // See: https://docs.keycloakify.dev/faq-and-help/some-values-you-need-are-missing-from-in-kccontext -}; - -export type KcContextExtensionPerPage = { - "register.ftl": { - social?: KcContext_base.Login["social"]; - }; -}; - -export type KcContext = ExtendKcContext; diff --git a/keycloak/keycloakify/src/login/KcPage.tsx b/keycloak/keycloakify/src/login/KcPage.tsx deleted file mode 100644 index 30cd0bcc4e..0000000000 --- a/keycloak/keycloakify/src/login/KcPage.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import "./index.css"; -import { Suspense, lazy } from "react"; -import type { ClassKey } from "keycloakify/login"; -import type { KcContext } from "./KcContext"; -import { useI18n } from "./i18n"; -import DefaultPage from "keycloakify/login/DefaultPage"; -import Template from "./Template"; -const UserProfileFormFields = lazy( - () => import("keycloakify/login/UserProfileFormFields") -); - -const doMakeUserConfirmPassword = true; - -const Login = lazy(() => import("./pages/Login")); -const Register = lazy(() => import("./pages/Register")); -const IdpReviewUserProfile = lazy(() => import("./pages/IdpReviewUserProfile")); - -export default function KcPage(props: { kcContext: KcContext }) { - const { kcContext } = props; - - const { i18n } = useI18n({ kcContext }); - - return ( - - {(() => { - switch (kcContext.pageId) { - case "login.ftl": - return ( - - ); - case "register.ftl": - return ( - - ); - case "idp-review-user-profile.ftl": - return ( - - ); - default: - return ( - - ); - } - })()} - - ); -} - -const classes = {} satisfies { [key in ClassKey]?: string }; diff --git a/keycloak/keycloakify/src/login/KcPageStory.tsx b/keycloak/keycloakify/src/login/KcPageStory.tsx deleted file mode 100644 index 71d07a51f6..0000000000 --- a/keycloak/keycloakify/src/login/KcPageStory.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { DeepPartial } from "keycloakify/tools/DeepPartial"; -import type { KcContext } from "./KcContext"; -import KcPage from "./KcPage"; -import { createGetKcContextMock } from "keycloakify/login/KcContext"; -import type { KcContextExtension, KcContextExtensionPerPage } from "./KcContext"; -import { themeNames, kcEnvDefaults } from "../kc.gen"; - -const kcContextExtension: KcContextExtension = { - themeName: themeNames[0], - properties: { - ...kcEnvDefaults - } -}; -const kcContextExtensionPerPage: KcContextExtensionPerPage = { - "register.ftl": {} -}; - -export const { getKcContextMock } = createGetKcContextMock({ - kcContextExtension, - kcContextExtensionPerPage, - overrides: {}, - overridesPerPage: {} -}); - -export function createKcPageStory(params: { - pageId: PageId; -}) { - const { pageId } = params; - - function KcPageStory(props: { - kcContext?: DeepPartial>; - }) { - const { kcContext: overrides } = props; - - const kcContextMock = getKcContextMock({ - pageId, - overrides - }); - - return ; - } - - return { KcPageStory }; -} diff --git a/keycloak/keycloakify/src/login/Template.tsx b/keycloak/keycloakify/src/login/Template.tsx deleted file mode 100644 index c8f17805aa..0000000000 --- a/keycloak/keycloakify/src/login/Template.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useEffect } from "react"; -import { clsx } from "keycloakify/tools/clsx"; -import { kcSanitize } from "keycloakify/lib/kcSanitize"; -import type { TemplateProps } from "keycloakify/login/TemplateProps"; -import { getKcClsx } from "keycloakify/login/lib/kcClsx"; -import { useSetClassName } from "keycloakify/tools/useSetClassName"; -import { useInitialize } from "keycloakify/login/Template.useInitialize"; -import type { I18n } from "./i18n"; -import type { KcContext } from "./KcContext"; - -export default function Template(props: TemplateProps) { - const { - displayInfo = false, - displayMessage = true, - displayRequiredFields = false, - headerNode, - socialProvidersNode = null, - infoNode = null, - documentTitle, - bodyClassName, - kcContext, - i18n, - doUseDefaultCss, - classes, - children - } = props; - - const { kcClsx } = getKcClsx({ doUseDefaultCss, classes }); - - const { msg, msgStr, currentLanguage, enabledLanguages } = i18n; - - const { auth, url, message, isAppInitiatedAction } = kcContext; - - const projectName = kcContext.properties.PROJECT_NAME; - - useEffect(() => { - document.title = documentTitle ?? msgStr("loginTitle", projectName); - }, []); - - useSetClassName({ - qualifiedName: "html", - className: kcClsx("kcHtmlClass") - }); - - useSetClassName({ - qualifiedName: "body", - className: bodyClassName ?? kcClsx("kcBodyClass") - }); - - const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss }); - - if (!isReadyToRender) { - return null; - } - - return ( -
-
-
- {msg("loginTitleHtml", projectName)} -
-
-
-
- {enabledLanguages.length > 1 && ( -
-
-
- - -
-
-
- )} - {(() => { - const node = !(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? ( -

{headerNode}

- ) : ( -
- - -
- - {msg("restartLoginTooltip")} -
-
-
- ); - - if (displayRequiredFields) { - return ( -
-
- - * - {msg("requiredFields")} - -
-
{node}
-
- ); - } - - return node; - })()} -
-
-
- {/* App-initiated actions should not see warning messages about the need to complete the action during login. */} - {displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && ( -
-
- {message.type === "success" && } - {message.type === "warning" && } - {message.type === "error" && } - {message.type === "info" && } -
- -
- )} - {children} - {auth !== undefined && auth.showTryAnotherWayLink && ( -
- -
- )} - {socialProvidersNode} - {displayInfo && ( -
-
- {infoNode} -
-
- )} -
-
-
-
- ); -} diff --git a/keycloak/keycloakify/src/login/assets/orcid-logo.svg b/keycloak/keycloakify/src/login/assets/orcid-logo.svg deleted file mode 100644 index d2309de007..0000000000 --- a/keycloak/keycloakify/src/login/assets/orcid-logo.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/keycloak/keycloakify/src/login/assets/tos_en.md b/keycloak/keycloakify/src/login/assets/tos_en.md deleted file mode 100644 index 69f8d8384e..0000000000 --- a/keycloak/keycloakify/src/login/assets/tos_en.md +++ /dev/null @@ -1,3 +0,0 @@ -# Terms of Service - -Unused atm - put markdown here if we use it diff --git a/keycloak/keycloakify/src/login/assets/tos_fr.md b/keycloak/keycloakify/src/login/assets/tos_fr.md deleted file mode 100644 index 6ebc10a258..0000000000 --- a/keycloak/keycloakify/src/login/assets/tos_fr.md +++ /dev/null @@ -1,3 +0,0 @@ -# Conditions générales d'utilisation - -Unused atm - put markdown here if we use it diff --git a/keycloak/keycloakify/src/login/i18n.ts b/keycloak/keycloakify/src/login/i18n.ts deleted file mode 100644 index 48a88917cf..0000000000 --- a/keycloak/keycloakify/src/login/i18n.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { i18nBuilder } from "keycloakify/login"; -import type { ThemeName } from "../kc.gen"; - -/** @see: https://docs.keycloakify.dev/i18n */ -const { useI18n, ofTypeI18n } = i18nBuilder.withThemeName().build(); - -type I18n = typeof ofTypeI18n; - -export { useI18n, type I18n }; diff --git a/keycloak/keycloakify/src/login/index.css b/keycloak/keycloakify/src/login/index.css deleted file mode 100644 index a07cc29015..0000000000 --- a/keycloak/keycloakify/src/login/index.css +++ /dev/null @@ -1,16 +0,0 @@ -.kcLoginClass, -.kcHtmlClass { - background: #efefef; -} - -.kcHeaderWrapperClass { - color: #363636; -} - -.login-pf body { - background: none; -} - -.card-pf { - border-top: none; -} diff --git a/keycloak/keycloakify/src/login/pages/IdpReviewUserProfile.tsx b/keycloak/keycloakify/src/login/pages/IdpReviewUserProfile.tsx deleted file mode 100644 index 6ecc2e8058..0000000000 --- a/keycloak/keycloakify/src/login/pages/IdpReviewUserProfile.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useState } from "react"; -import type { LazyOrNot } from "keycloakify/tools/LazyOrNot"; -import { getKcClsx } from "keycloakify/login/lib/kcClsx"; -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps"; -import type { KcContext } from "../KcContext"; -import type { I18n } from "../i18n"; -import { TermsAcceptance } from "./TermsAcceptance"; - -type IdpReviewUserProfileProps = PageProps, I18n> & { - UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>; - doMakeUserConfirmPassword: boolean; -}; - -export default function IdpReviewUserProfile(props: IdpReviewUserProfileProps) { - const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields, doMakeUserConfirmPassword } = props; - - // https://github.com/loculus-project/loculus/issues/3284 - const termsAcceptanceRequired = true; - - const { kcClsx } = getKcClsx({ - doUseDefaultCss, - classes - }); - - const { msg, msgStr } = i18n; - - const { url, messagesPerField } = kcContext; - - const [isFomSubmittable, setIsFomSubmittable] = useState(false); - const [areTermsAccepted, setAreTermsAccepted] = useState(false); - - return ( - - ); -} diff --git a/keycloak/keycloakify/src/login/pages/Login.stories.tsx b/keycloak/keycloakify/src/login/pages/Login.stories.tsx deleted file mode 100644 index a0a6b9b69d..0000000000 --- a/keycloak/keycloakify/src/login/pages/Login.stories.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { createKcPageStory } from "../KcPageStory"; - -const { KcPageStory } = createKcPageStory({ pageId: "login.ftl" }); - -const meta = { - title: "login/login.ftl", - component: KcPageStory -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - render: () => -}; - -export const WithInvalidCredential: Story = { - render: () => ( - { - const fieldNames = [fieldName, ...otherFieldNames]; - return fieldNames.includes("username") || fieldNames.includes("password"); - }, - get: (fieldName: string) => { - if (fieldName === "username" || fieldName === "password") { - return "Invalid username or password."; - } - return ""; - } - } - }} - /> - ) -}; - -export const WithoutRegistration: Story = { - render: () => ( - - ) -}; - -export const WithoutRememberMe: Story = { - render: () => ( - - ) -}; - -export const WithoutPasswordReset: Story = { - render: () => ( - - ) -}; - -export const WithEmailAsUsername: Story = { - render: () => ( - - ) -}; - -export const WithPresetUsername: Story = { - render: () => ( - - ) -}; - -export const WithImmutablePresetUsername: Story = { - render: () => ( - - ) -}; - -export const WithSocialProviders: Story = { - render: () => ( - - ) -}; - -export const WithoutPasswordField: Story = { - render: () => ( - - ) -}; - -export const WithErrorMessage: Story = { - render: () => ( - The login process will restart from the beginning.", - type: "error" - } - }} - /> - ) -}; - -export const WithORCID: Story = { - render: args => ( - - ) -}; - -export const WithOneSocialProvider: Story = { - render: args => ( - - ) -}; - -export const WithTwoSocialProviders: Story = { - render: args => ( - - ) -}; -export const WithNoSocialProviders: Story = { - render: args => ( - - ) -}; -export const WithMoreThanTwoSocialProviders: Story = { - render: args => ( - - ) -}; -export const WithSocialProvidersAndWithoutRememberMe: Story = { - render: args => ( - - ) -}; diff --git a/keycloak/keycloakify/src/login/pages/Login.tsx b/keycloak/keycloakify/src/login/pages/Login.tsx deleted file mode 100644 index b590065cb6..0000000000 --- a/keycloak/keycloakify/src/login/pages/Login.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { useState, useEffect, useReducer } from "react"; -import { kcSanitize } from "keycloakify/lib/kcSanitize"; -import { assert } from "keycloakify/tools/assert"; -import { clsx } from "keycloakify/tools/clsx"; -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; -import type { KcContext } from "../KcContext"; -import type { I18n } from "../i18n"; -import orcidLogoUrl from "../assets/orcid-logo.svg"; - -export default function Login(props: PageProps, I18n>) { - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - const { kcClsx } = getKcClsx({ - doUseDefaultCss, - classes - }); - - const { social, realm, url, usernameHidden, login, auth, registrationDisabled, messagesPerField } = kcContext; - - const { msg, msgStr } = i18n; - - const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false); - - return ( - - ); -} - -function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: string; children: JSX.Element }) { - const { kcClsx, i18n, passwordInputId, children } = props; - - const { msgStr } = i18n; - - const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false); - - useEffect(() => { - const passwordInputElement = document.getElementById(passwordInputId); - - assert(passwordInputElement instanceof HTMLInputElement); - - passwordInputElement.type = isPasswordRevealed ? "text" : "password"; - }, [isPasswordRevealed]); - - return ( -
- {children} - -
- ); -} diff --git a/keycloak/keycloakify/src/login/pages/Register.stories.tsx b/keycloak/keycloakify/src/login/pages/Register.stories.tsx deleted file mode 100644 index 3ab4e5b9d5..0000000000 --- a/keycloak/keycloakify/src/login/pages/Register.stories.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { createKcPageStory } from "../KcPageStory"; -import type { Attribute } from "keycloakify/login"; - -const { KcPageStory } = createKcPageStory({ pageId: "register.ftl" }); - -const meta = { - title: "login/register.ftl", - component: KcPageStory -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - render: () => -}; - -export const WithORCID: Story = { - render: () => ( - - ) -}; - -export const WithEmailAlreadyExists: Story = { - render: () => ( - [fieldName, ...otherFieldNames].includes("email"), - get: (fieldName: string) => (fieldName === "email" ? "Email already exists." : undefined) - } - }} - /> - ) -}; - -export const WithRestrictedToMITStudents: Story = { - render: () => ( - @mit.edu) nor a Berkeley (@berkeley.edu) email." - } - } - }} - /> - ) -}; - -export const WithFavoritePet: Story = { - render: () => ( - - ) -}; - -export const WithNewsletter: Story = { - render: () => ( - - ) -}; - -export const WithEmailAsUsername: Story = { - render: () => ( - - ) -}; - -export const WithRecaptcha: Story = { - render: () => ( - - ) -}; - -export const WithRecaptchaFrench: Story = { - render: () => ( - - ) -}; - -export const WithPasswordMinLength8: Story = { - render: () => ( - - ) -}; - -export const WithTermsAcceptance: Story = { - render: () => ( - Service Terms of Use" - } - } - }} - /> - ) -}; - -export const WithTermsAcceptanceWithORCID: Story = { - render: () => ( - - ) -}; - -export const WithTermsNotAccepted: Story = { - render: args => ( - fieldName === "termsAccepted", - get: (fieldName: string) => (fieldName === "termsAccepted" ? "You must accept the terms." : undefined) - } - }} - /> - ) -}; -export const WithFieldErrors: Story = { - render: () => ( - ["username", "email"].includes(fieldName), - get: (fieldName: string) => { - if (fieldName === "username") return "Username is required."; - if (fieldName === "email") return "Invalid email format."; - } - } - }} - /> - ) -}; -export const WithReadOnlyFields: Story = { - render: () => ( - - ) -}; -export const WithAutoGeneratedUsername: Story = { - render: () => ( - - ) -}; diff --git a/keycloak/keycloakify/src/login/pages/Register.tsx b/keycloak/keycloakify/src/login/pages/Register.tsx deleted file mode 100644 index 6687e208a5..0000000000 --- a/keycloak/keycloakify/src/login/pages/Register.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { useState } from "react"; -import type { LazyOrNot } from "keycloakify/tools/LazyOrNot"; -import { kcSanitize } from "keycloakify/lib/kcSanitize"; -import { getKcClsx } from "keycloakify/login/lib/kcClsx"; -import { clsx } from "keycloakify/tools/clsx"; -import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps"; -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import type { KcContext } from "../KcContext"; -import type { I18n } from "../i18n"; -import orcidLogoUrl from "../assets/orcid-logo.svg"; -import { TermsAcceptance } from "./TermsAcceptance"; - -type RegisterProps = PageProps, I18n> & { - UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>; - doMakeUserConfirmPassword: boolean; -}; - -export default function Register(props: RegisterProps) { - const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields, doMakeUserConfirmPassword } = props; - - const { kcClsx } = getKcClsx({ - doUseDefaultCss, - classes - }); - - const { - messageHeader, - url, - social, - messagesPerField, - recaptchaRequired, - recaptchaVisible, - recaptchaSiteKey, - recaptchaAction - // termsAcceptanceRequired - } = kcContext; - - // https://github.com/loculus-project/loculus/issues/3284 - const termsAcceptanceRequired = true; - - const { msg, msgStr, advancedMsg } = i18n; - - const [isFormSubmittable, setIsFormSubmittable] = useState(false); - const [areTermsAccepted, setAreTermsAccepted] = useState(false); - - return ( - - ); -} diff --git a/keycloak/keycloakify/src/login/pages/TermsAcceptance.tsx b/keycloak/keycloakify/src/login/pages/TermsAcceptance.tsx deleted file mode 100644 index 6388ebcd28..0000000000 --- a/keycloak/keycloakify/src/login/pages/TermsAcceptance.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { kcSanitize } from "keycloakify/lib/kcSanitize"; -import type { KcClsx } from "keycloakify/login/lib/kcClsx"; -import type { KcContext } from "../KcContext"; -import type { I18n } from "../i18n"; - -export function TermsAcceptance(props: { - kcContext: KcContext; - i18n: I18n; - kcClsx: KcClsx; - messagesPerField: Pick; - areTermsAccepted: boolean; - onAreTermsAcceptedValueChange: (areTermsAccepted: boolean) => void; -}) { - const { kcContext, kcClsx, messagesPerField, areTermsAccepted, onAreTermsAcceptedValueChange } = props; - - return ( - <> -
-
-
-
-
- onAreTermsAcceptedValueChange(e.target.checked)} - aria-invalid={messagesPerField.existsError("termsAccepted")} - /> - -
- {messagesPerField.existsError("termsAccepted") && ( -
- -
- )} -
- - ); -} diff --git a/keycloak/keycloakify/src/main.tsx b/keycloak/keycloakify/src/main.tsx deleted file mode 100644 index 2e7e36e762..0000000000 --- a/keycloak/keycloakify/src/main.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ -import { createRoot } from "react-dom/client"; -import { StrictMode } from "react"; -import { KcPage } from "./kc.gen"; - -// The following block can be uncommented to test a specific page with `yarn dev` -// Don't forget to comment back or your bundle size will increase -/* -import { getKcContextMock } from "./login/KcPageStory"; - -if (import.meta.env.DEV) { - window.kcContext = getKcContextMock({ - pageId: "register.ftl", - overrides: {} - }); -} -*/ - -createRoot(document.getElementById("root")!).render( - - {!window.kcContext ? ( -

No Keycloak Context

- ) : ( - - )} -
-); diff --git a/keycloak/keycloakify/src/vite-env.d.ts b/keycloak/keycloakify/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/keycloak/keycloakify/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/keycloak/keycloakify/tsconfig.json b/keycloak/keycloakify/tsconfig.json deleted file mode 100644 index 30d6ff14f2..0000000000 --- a/keycloak/keycloakify/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/keycloak/keycloakify/tsconfig.node.json b/keycloak/keycloakify/tsconfig.node.json deleted file mode 100644 index 26063d8571..0000000000 --- a/keycloak/keycloakify/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/keycloak/keycloakify/vite.config.ts b/keycloak/keycloakify/vite.config.ts deleted file mode 100644 index 2b588382bd..0000000000 --- a/keycloak/keycloakify/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import { keycloakify } from "keycloakify/vite-plugin"; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [ - react(), - keycloakify({ - accountThemeImplementation: "none", - themeName: "loculus", - environmentVariables: [ - { name: "PROJECT_NAME", default: "Loculus" }, - { name: "REGISTRATION_TERMS_MESSAGE", default: "" } - ] - }) - ] -}); diff --git a/keycloak/keycloakify/yarn.lock b/keycloak/keycloakify/yarn.lock deleted file mode 100644 index a6f9083ce6..0000000000 --- a/keycloak/keycloakify/yarn.lock +++ /dev/null @@ -1,4809 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"@adobe/css-tools@npm:^4.4.0": - version: 4.4.3 - resolution: "@adobe/css-tools@npm:4.4.3" - checksum: 10c0/6d16c4d4b6752d73becf6e58611f893c7ed96e04017ff7084310901ccdbe0295171b722b158f6a2b0aa77182ef3446ffd62b39488fa5a7adab1f0dfe5ffafbae - languageName: node - linkType: hard - -"@ampproject/remapping@npm:^2.2.0": - version: 2.3.0 - resolution: "@ampproject/remapping@npm:2.3.0" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed - languageName: node - linkType: hard - -"@babel/code-frame@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/code-frame@npm:7.24.7" - dependencies: - "@babel/highlight": "npm:^7.24.7" - picocolors: "npm:^1.0.0" - checksum: 10c0/ab0af539473a9f5aeaac7047e377cb4f4edd255a81d84a76058595f8540784cc3fbe8acf73f1e073981104562490aabfb23008cd66dc677a456a4ed5390fdde6 - languageName: node - linkType: hard - -"@babel/code-frame@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/code-frame@npm:7.27.1" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.27.1" - js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.1.1" - checksum: 10c0/5dd9a18baa5fce4741ba729acc3a3272c49c25cb8736c4b18e113099520e7ef7b545a4096a26d600e4416157e63e87d66db46aa3fbf0a5f2286da2705c12da00 - languageName: node - linkType: hard - -"@babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/code-frame@npm:7.29.0" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.28.5" - js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.1.1" - checksum: 10c0/d34cc504e7765dfb576a663d97067afb614525806b5cad1a5cc1a7183b916fec8ff57fa233585e3926fd5a9e6b31aae6df91aa81ae9775fb7a28f658d3346f0d - languageName: node - linkType: hard - -"@babel/compat-data@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/compat-data@npm:7.24.7" - checksum: 10c0/dcd93a5632b04536498fbe2be5af1057f635fd7f7090483d8e797878559037e5130b26862ceb359acbae93ed27e076d395ddb4663db6b28a665756ffd02d324f - languageName: node - linkType: hard - -"@babel/compat-data@npm:^7.27.2": - version: 7.27.5 - resolution: "@babel/compat-data@npm:7.27.5" - checksum: 10c0/da2751fcd0b58eea958f2b2f7ff7d6de1280712b709fa1ad054b73dc7d31f589e353bb50479b9dc96007935f3ed3cada68ac5b45ce93086b7122ddc32e60dc00 - languageName: node - linkType: hard - -"@babel/compat-data@npm:^7.28.6": - version: 7.29.0 - resolution: "@babel/compat-data@npm:7.29.0" - checksum: 10c0/08f348554989d23aa801bf1405aa34b15e841c0d52d79da7e524285c77a5f9d298e70e11d91cc578d8e2c9542efc586d50c5f5cf8e1915b254a9dcf786913a94 - languageName: node - linkType: hard - -"@babel/core@npm:^7.18.9": - version: 7.24.7 - resolution: "@babel/core@npm:7.24.7" - dependencies: - "@ampproject/remapping": "npm:^2.2.0" - "@babel/code-frame": "npm:^7.24.7" - "@babel/generator": "npm:^7.24.7" - "@babel/helper-compilation-targets": "npm:^7.24.7" - "@babel/helper-module-transforms": "npm:^7.24.7" - "@babel/helpers": "npm:^7.24.7" - "@babel/parser": "npm:^7.24.7" - "@babel/template": "npm:^7.24.7" - "@babel/traverse": "npm:^7.24.7" - "@babel/types": "npm:^7.24.7" - convert-source-map: "npm:^2.0.0" - debug: "npm:^4.1.0" - gensync: "npm:^1.0.0-beta.2" - json5: "npm:^2.2.3" - semver: "npm:^6.3.1" - checksum: 10c0/4004ba454d3c20a46ea66264e06c15b82e9f6bdc35f88819907d24620da70dbf896abac1cb4cc4b6bb8642969e45f4d808497c9054a1388a386cf8c12e9b9e0d - languageName: node - linkType: hard - -"@babel/core@npm:^7.24.4": - version: 7.28.4 - resolution: "@babel/core@npm:7.28.4" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.3" - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-module-transforms": "npm:^7.28.3" - "@babel/helpers": "npm:^7.28.4" - "@babel/parser": "npm:^7.28.4" - "@babel/template": "npm:^7.27.2" - "@babel/traverse": "npm:^7.28.4" - "@babel/types": "npm:^7.28.4" - "@jridgewell/remapping": "npm:^2.3.5" - convert-source-map: "npm:^2.0.0" - debug: "npm:^4.1.0" - gensync: "npm:^1.0.0-beta.2" - json5: "npm:^2.2.3" - semver: "npm:^6.3.1" - checksum: 10c0/ef5a6c3c6bf40d3589b5593f8118cfe2602ce737412629fb6e26d595be2fcbaae0807b43027a5c42ec4fba5b895ff65891f2503b5918c8a3ea3542ab44d4c278 - languageName: node - linkType: hard - -"@babel/core@npm:^7.28.0": - version: 7.28.5 - resolution: "@babel/core@npm:7.28.5" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.5" - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-module-transforms": "npm:^7.28.3" - "@babel/helpers": "npm:^7.28.4" - "@babel/parser": "npm:^7.28.5" - "@babel/template": "npm:^7.27.2" - "@babel/traverse": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" - "@jridgewell/remapping": "npm:^2.3.5" - convert-source-map: "npm:^2.0.0" - debug: "npm:^4.1.0" - gensync: "npm:^1.0.0-beta.2" - json5: "npm:^2.2.3" - semver: "npm:^6.3.1" - checksum: 10c0/535f82238027621da6bdffbdbe896ebad3558b311d6f8abc680637a9859b96edbf929ab010757055381570b29cf66c4a295b5618318d27a4273c0e2033925e72 - languageName: node - linkType: hard - -"@babel/core@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/core@npm:7.29.0" - dependencies: - "@babel/code-frame": "npm:^7.29.0" - "@babel/generator": "npm:^7.29.0" - "@babel/helper-compilation-targets": "npm:^7.28.6" - "@babel/helper-module-transforms": "npm:^7.28.6" - "@babel/helpers": "npm:^7.28.6" - "@babel/parser": "npm:^7.29.0" - "@babel/template": "npm:^7.28.6" - "@babel/traverse": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" - "@jridgewell/remapping": "npm:^2.3.5" - convert-source-map: "npm:^2.0.0" - debug: "npm:^4.1.0" - gensync: "npm:^1.0.0-beta.2" - json5: "npm:^2.2.3" - semver: "npm:^6.3.1" - checksum: 10c0/5127d2e8e842ae409e11bcbb5c2dff9874abf5415e8026925af7308e903f4f43397341467a130490d1a39884f461bc2b67f3063bce0be44340db89687fd852aa - languageName: node - linkType: hard - -"@babel/generator@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/generator@npm:7.24.7" - dependencies: - "@babel/types": "npm:^7.24.7" - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.25" - jsesc: "npm:^2.5.1" - checksum: 10c0/06b1f3350baf527a3309e50ffd7065f7aee04dd06e1e7db794ddfde7fe9d81f28df64edd587173f8f9295496a7ddb74b9a185d4bf4de7bb619e6d4ec45c8fd35 - languageName: node - linkType: hard - -"@babel/generator@npm:^7.27.3": - version: 7.27.5 - resolution: "@babel/generator@npm:7.27.5" - dependencies: - "@babel/parser": "npm:^7.27.5" - "@babel/types": "npm:^7.27.3" - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.25" - jsesc: "npm:^3.0.2" - checksum: 10c0/8f649ef4cd81765c832bb11de4d6064b035ffebdecde668ba7abee68a7b0bce5c9feabb5dc5bb8aeba5bd9e5c2afa3899d852d2bd9ca77a711ba8c8379f416f0 - languageName: node - linkType: hard - -"@babel/generator@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/generator@npm:7.28.3" - dependencies: - "@babel/parser": "npm:^7.28.3" - "@babel/types": "npm:^7.28.2" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/0ff58bcf04f8803dcc29479b547b43b9b0b828ec1ee0668e92d79f9e90f388c28589056637c5ff2fd7bcf8d153c990d29c448d449d852bf9d1bc64753ca462bc - languageName: node - linkType: hard - -"@babel/generator@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/generator@npm:7.28.5" - dependencies: - "@babel/parser": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/9f219fe1d5431b6919f1a5c60db8d5d34fe546c0d8f5a8511b32f847569234ffc8032beb9e7404649a143f54e15224ecb53a3d11b6bb85c3203e573d91fca752 - languageName: node - linkType: hard - -"@babel/generator@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/generator@npm:7.29.0" - dependencies: - "@babel/parser": "npm:^7.29.0" - "@babel/types": "npm:^7.29.0" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/5c3df8f2475bfd5f97ad0211c52171aff630088b148e7b89d056b39d69855179bc9f2d1ee200263c76c2398a49e4fdbb38b9709ebc4f043cc04d9ee09a66668a - languageName: node - linkType: hard - -"@babel/helper-compilation-targets@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-compilation-targets@npm:7.24.7" - dependencies: - "@babel/compat-data": "npm:^7.24.7" - "@babel/helper-validator-option": "npm:^7.24.7" - browserslist: "npm:^4.22.2" - lru-cache: "npm:^5.1.1" - semver: "npm:^6.3.1" - checksum: 10c0/1d580a9bcacefe65e6bf02ba1dafd7ab278269fef45b5e281d8354d95c53031e019890464e7f9351898c01502dd2e633184eb0bcda49ed2ecd538675ce310f51 - languageName: node - linkType: hard - -"@babel/helper-compilation-targets@npm:^7.27.2": - version: 7.27.2 - resolution: "@babel/helper-compilation-targets@npm:7.27.2" - dependencies: - "@babel/compat-data": "npm:^7.27.2" - "@babel/helper-validator-option": "npm:^7.27.1" - browserslist: "npm:^4.24.0" - lru-cache: "npm:^5.1.1" - semver: "npm:^6.3.1" - checksum: 10c0/f338fa00dcfea931804a7c55d1a1c81b6f0a09787e528ec580d5c21b3ecb3913f6cb0f361368973ce953b824d910d3ac3e8a8ee15192710d3563826447193ad1 - languageName: node - linkType: hard - -"@babel/helper-compilation-targets@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-compilation-targets@npm:7.28.6" - dependencies: - "@babel/compat-data": "npm:^7.28.6" - "@babel/helper-validator-option": "npm:^7.27.1" - browserslist: "npm:^4.24.0" - lru-cache: "npm:^5.1.1" - semver: "npm:^6.3.1" - checksum: 10c0/3fcdf3b1b857a1578e99d20508859dbd3f22f3c87b8a0f3dc540627b4be539bae7f6e61e49d931542fe5b557545347272bbdacd7f58a5c77025a18b745593a50 - languageName: node - linkType: hard - -"@babel/helper-environment-visitor@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-environment-visitor@npm:7.24.7" - dependencies: - "@babel/types": "npm:^7.24.7" - checksum: 10c0/36ece78882b5960e2d26abf13cf15ff5689bf7c325b10a2895a74a499e712de0d305f8d78bb382dd3c05cfba7e47ec98fe28aab5674243e0625cd38438dd0b2d - languageName: node - linkType: hard - -"@babel/helper-function-name@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-function-name@npm:7.24.7" - dependencies: - "@babel/template": "npm:^7.24.7" - "@babel/types": "npm:^7.24.7" - checksum: 10c0/e5e41e6cf86bd0f8bf272cbb6e7c5ee0f3e9660414174435a46653efba4f2479ce03ce04abff2aa2ef9359cf057c79c06cb7b134a565ad9c0e8a50dcdc3b43c4 - languageName: node - linkType: hard - -"@babel/helper-globals@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/helper-globals@npm:7.28.0" - checksum: 10c0/5a0cd0c0e8c764b5f27f2095e4243e8af6fa145daea2b41b53c0c1414fe6ff139e3640f4e2207ae2b3d2153a1abd346f901c26c290ee7cb3881dd922d4ee9232 - languageName: node - linkType: hard - -"@babel/helper-hoist-variables@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-hoist-variables@npm:7.24.7" - dependencies: - "@babel/types": "npm:^7.24.7" - checksum: 10c0/19ee37563bbd1219f9d98991ad0e9abef77803ee5945fd85aa7aa62a67c69efca9a801696a1b58dda27f211e878b3327789e6fd2a6f6c725ccefe36774b5ce95 - languageName: node - linkType: hard - -"@babel/helper-module-imports@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-module-imports@npm:7.24.7" - dependencies: - "@babel/traverse": "npm:^7.24.7" - "@babel/types": "npm:^7.24.7" - checksum: 10c0/97c57db6c3eeaea31564286e328a9fb52b0313c5cfcc7eee4bc226aebcf0418ea5b6fe78673c0e4a774512ec6c86e309d0f326e99d2b37bfc16a25a032498af0 - languageName: node - linkType: hard - -"@babel/helper-module-imports@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-module-imports@npm:7.27.1" - dependencies: - "@babel/traverse": "npm:^7.27.1" - "@babel/types": "npm:^7.27.1" - checksum: 10c0/e00aace096e4e29290ff8648455c2bc4ed982f0d61dbf2db1b5e750b9b98f318bf5788d75a4f974c151bd318fd549e81dbcab595f46b14b81c12eda3023f51e8 - languageName: node - linkType: hard - -"@babel/helper-module-imports@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-module-imports@npm:7.28.6" - dependencies: - "@babel/traverse": "npm:^7.28.6" - "@babel/types": "npm:^7.28.6" - checksum: 10c0/b49d8d8f204d9dbfd5ac70c54e533e5269afb3cea966a9d976722b13e9922cc773a653405f53c89acb247d5aebdae4681d631a3ae3df77ec046b58da76eda2ac - languageName: node - linkType: hard - -"@babel/helper-module-transforms@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-module-transforms@npm:7.24.7" - dependencies: - "@babel/helper-environment-visitor": "npm:^7.24.7" - "@babel/helper-module-imports": "npm:^7.24.7" - "@babel/helper-simple-access": "npm:^7.24.7" - "@babel/helper-split-export-declaration": "npm:^7.24.7" - "@babel/helper-validator-identifier": "npm:^7.24.7" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10c0/4f311755fcc3b4cbdb689386309cdb349cf0575a938f0b9ab5d678e1a81bbb265aa34ad93174838245f2ac7ff6d5ddbd0104638a75e4e961958ed514355687b6 - languageName: node - linkType: hard - -"@babel/helper-module-transforms@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/helper-module-transforms@npm:7.28.3" - dependencies: - "@babel/helper-module-imports": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.3" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10c0/549be62515a6d50cd4cfefcab1b005c47f89bd9135a22d602ee6a5e3a01f27571868ada10b75b033569f24dc4a2bb8d04bfa05ee75c16da7ade2d0db1437fcdb - languageName: node - linkType: hard - -"@babel/helper-module-transforms@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helper-module-transforms@npm:7.28.6" - dependencies: - "@babel/helper-module-imports": "npm:^7.28.6" - "@babel/helper-validator-identifier": "npm:^7.28.5" - "@babel/traverse": "npm:^7.28.6" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10c0/6f03e14fc30b287ce0b839474b5f271e72837d0cafe6b172d759184d998fbee3903a035e81e07c2c596449e504f453463d58baa65b6f40a37ded5bec74620b2b - languageName: node - linkType: hard - -"@babel/helper-plugin-utils@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-plugin-utils@npm:7.27.1" - checksum: 10c0/94cf22c81a0c11a09b197b41ab488d416ff62254ce13c57e62912c85700dc2e99e555225787a4099ff6bae7a1812d622c80fbaeda824b79baa10a6c5ac4cf69b - languageName: node - linkType: hard - -"@babel/helper-simple-access@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-simple-access@npm:7.24.7" - dependencies: - "@babel/traverse": "npm:^7.24.7" - "@babel/types": "npm:^7.24.7" - checksum: 10c0/7230e419d59a85f93153415100a5faff23c133d7442c19e0cd070da1784d13cd29096ee6c5a5761065c44e8164f9f80e3a518c41a0256df39e38f7ad6744fed7 - languageName: node - linkType: hard - -"@babel/helper-split-export-declaration@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-split-export-declaration@npm:7.24.7" - dependencies: - "@babel/types": "npm:^7.24.7" - checksum: 10c0/0254577d7086bf09b01bbde98f731d4fcf4b7c3fa9634fdb87929801307c1f6202a1352e3faa5492450fa8da4420542d44de604daf540704ff349594a78184f6 - languageName: node - linkType: hard - -"@babel/helper-string-parser@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-string-parser@npm:7.24.7" - checksum: 10c0/47840c7004e735f3dc93939c77b099bb41a64bf3dda0cae62f60e6f74a5ff80b63e9b7cf77b5ec25a324516381fc994e1f62f922533236a8e3a6af57decb5e1e - languageName: node - linkType: hard - -"@babel/helper-string-parser@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-string-parser@npm:7.25.9" - checksum: 10c0/7244b45d8e65f6b4338a6a68a8556f2cb161b782343e97281a5f2b9b93e420cad0d9f5773a59d79f61d0c448913d06f6a2358a87f2e203cf112e3c5b53522ee6 - languageName: node - linkType: hard - -"@babel/helper-string-parser@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-string-parser@npm:7.27.1" - checksum: 10c0/8bda3448e07b5583727c103560bcf9c4c24b3c1051a4c516d4050ef69df37bb9a4734a585fe12725b8c2763de0a265aa1e909b485a4e3270b7cfd3e4dbe4b602 - languageName: node - linkType: hard - -"@babel/helper-validator-identifier@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-validator-identifier@npm:7.24.7" - checksum: 10c0/87ad608694c9477814093ed5b5c080c2e06d44cb1924ae8320474a74415241223cc2a725eea2640dd783ff1e3390e5f95eede978bc540e870053152e58f1d651 - languageName: node - linkType: hard - -"@babel/helper-validator-identifier@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-validator-identifier@npm:7.25.9" - checksum: 10c0/4fc6f830177b7b7e887ad3277ddb3b91d81e6c4a24151540d9d1023e8dc6b1c0505f0f0628ae653601eb4388a8db45c1c14b2c07a9173837aef7e4116456259d - languageName: node - linkType: hard - -"@babel/helper-validator-identifier@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-validator-identifier@npm:7.27.1" - checksum: 10c0/c558f11c4871d526498e49d07a84752d1800bf72ac0d3dad100309a2eaba24efbf56ea59af5137ff15e3a00280ebe588560534b0e894a4750f8b1411d8f78b84 - languageName: node - linkType: hard - -"@babel/helper-validator-identifier@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/helper-validator-identifier@npm:7.28.5" - checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 - languageName: node - linkType: hard - -"@babel/helper-validator-option@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-validator-option@npm:7.24.7" - checksum: 10c0/21aea2b7bc5cc8ddfb828741d5c8116a84cbc35b4a3184ec53124f08e09746f1f67a6f9217850188995ca86059a7942e36d8965a6730784901def777b7e8a436 - languageName: node - linkType: hard - -"@babel/helper-validator-option@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-validator-option@npm:7.27.1" - checksum: 10c0/6fec5f006eba40001a20f26b1ef5dbbda377b7b68c8ad518c05baa9af3f396e780bdfded24c4eef95d14bb7b8fd56192a6ed38d5d439b97d10efc5f1a191d148 - languageName: node - linkType: hard - -"@babel/helpers@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helpers@npm:7.24.7" - dependencies: - "@babel/template": "npm:^7.24.7" - "@babel/types": "npm:^7.24.7" - checksum: 10c0/aa8e230f6668773e17e141dbcab63e935c514b4b0bf1fed04d2eaefda17df68e16b61a56573f7f1d4d1e605ce6cc162b5f7e9fdf159fde1fd9b77c920ae47d27 - languageName: node - linkType: hard - -"@babel/helpers@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/helpers@npm:7.28.4" - dependencies: - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.4" - checksum: 10c0/aaa5fb8098926dfed5f223adf2c5e4c7fbba4b911b73dfec2d7d3083f8ba694d201a206db673da2d9b3ae8c01793e795767654558c450c8c14b4c2175b4fcb44 - languageName: node - linkType: hard - -"@babel/helpers@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/helpers@npm:7.28.6" - dependencies: - "@babel/template": "npm:^7.28.6" - "@babel/types": "npm:^7.28.6" - checksum: 10c0/c4a779c66396bb0cf619402d92f1610601ff3832db2d3b86b9c9dd10983bf79502270e97ac6d5280cea1b1a37de2f06ecbac561bd2271545270407fbe64027cb - languageName: node - linkType: hard - -"@babel/highlight@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/highlight@npm:7.24.7" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.24.7" - chalk: "npm:^2.4.2" - js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.0.0" - checksum: 10c0/674334c571d2bb9d1c89bdd87566383f59231e16bcdcf5bb7835babdf03c9ae585ca0887a7b25bdf78f303984af028df52831c7989fecebb5101cc132da9393a - languageName: node - linkType: hard - -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/parser@npm:7.24.7" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/8b244756872185a1c6f14b979b3535e682ff08cb5a2a5fd97cc36c017c7ef431ba76439e95e419d43000c5b07720495b00cf29a7f0d9a483643d08802b58819b - languageName: node - linkType: hard - -"@babel/parser@npm:^7.24.4, @babel/parser@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/parser@npm:7.28.4" - dependencies: - "@babel/types": "npm:^7.28.4" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/58b239a5b1477ac7ed7e29d86d675cc81075ca055424eba6485872626db2dc556ce63c45043e5a679cd925e999471dba8a3ed4864e7ab1dbf64306ab72c52707 - languageName: node - linkType: hard - -"@babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.4, @babel/parser@npm:^7.27.5": - version: 7.27.5 - resolution: "@babel/parser@npm:7.27.5" - dependencies: - "@babel/types": "npm:^7.27.3" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/f7faaebf21cc1f25d9ca8ac02c447ed38ef3460ea95be7ea760916dcf529476340d72a5a6010c6641d9ed9d12ad827c8424840277ec2295c5b082ba0f291220a - languageName: node - linkType: hard - -"@babel/parser@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/parser@npm:7.28.3" - dependencies: - "@babel/types": "npm:^7.28.2" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/1f41eb82623b0ca0f94521b57f4790c6c457cd922b8e2597985b36bdec24114a9ccf54640286a760ceb60f11fe9102d192bf60477aee77f5d45f1029b9b72729 - languageName: node - linkType: hard - -"@babel/parser@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/parser@npm:7.28.5" - dependencies: - "@babel/types": "npm:^7.28.5" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/5bbe48bf2c79594ac02b490a41ffde7ef5aa22a9a88ad6bcc78432a6ba8a9d638d531d868bd1f104633f1f6bba9905746e15185b8276a3756c42b765d131b1ef - languageName: node - linkType: hard - -"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/parser@npm:7.29.0" - dependencies: - "@babel/types": "npm:^7.29.0" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/333b2aa761264b91577a74bee86141ef733f9f9f6d4fc52548e4847dc35dfbf821f58c46832c637bfa761a6d9909d6a68f7d1ed59e17e4ffbb958dc510c17b62 - languageName: node - linkType: hard - -"@babel/plugin-transform-react-jsx-self@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-react-jsx-self@npm:7.27.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/00a4f917b70a608f9aca2fb39aabe04a60aa33165a7e0105fd44b3a8531630eb85bf5572e9f242f51e6ad2fa38c2e7e780902176c863556c58b5ba6f6e164031 - languageName: node - linkType: hard - -"@babel/plugin-transform-react-jsx-source@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-react-jsx-source@npm:7.27.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/5e67b56c39c4d03e59e03ba80692b24c5a921472079b63af711b1d250fc37c1733a17069b63537f750f3e937ec44a42b1ee6a46cd23b1a0df5163b17f741f7f2 - languageName: node - linkType: hard - -"@babel/template@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/template@npm:7.24.7" - dependencies: - "@babel/code-frame": "npm:^7.24.7" - "@babel/parser": "npm:^7.24.7" - "@babel/types": "npm:^7.24.7" - checksum: 10c0/95b0b3ee80fcef685b7f4426f5713a855ea2cd5ac4da829b213f8fb5afe48a2a14683c2ea04d446dbc7f711c33c5cd4a965ef34dcbe5bc387c9e966b67877ae3 - languageName: node - linkType: hard - -"@babel/template@npm:^7.27.2": - version: 7.27.2 - resolution: "@babel/template@npm:7.27.2" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/parser": "npm:^7.27.2" - "@babel/types": "npm:^7.27.1" - checksum: 10c0/ed9e9022651e463cc5f2cc21942f0e74544f1754d231add6348ff1b472985a3b3502041c0be62dc99ed2d12cfae0c51394bf827452b98a2f8769c03b87aadc81 - languageName: node - linkType: hard - -"@babel/template@npm:^7.28.6": - version: 7.28.6 - resolution: "@babel/template@npm:7.28.6" - dependencies: - "@babel/code-frame": "npm:^7.28.6" - "@babel/parser": "npm:^7.28.6" - "@babel/types": "npm:^7.28.6" - checksum: 10c0/66d87225ed0bc77f888181ae2d97845021838c619944877f7c4398c6748bcf611f216dfd6be74d39016af502bca876e6ce6873db3c49e4ac354c56d34d57e9f5 - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/traverse@npm:7.24.7" - dependencies: - "@babel/code-frame": "npm:^7.24.7" - "@babel/generator": "npm:^7.24.7" - "@babel/helper-environment-visitor": "npm:^7.24.7" - "@babel/helper-function-name": "npm:^7.24.7" - "@babel/helper-hoist-variables": "npm:^7.24.7" - "@babel/helper-split-export-declaration": "npm:^7.24.7" - "@babel/parser": "npm:^7.24.7" - "@babel/types": "npm:^7.24.7" - debug: "npm:^4.3.1" - globals: "npm:^11.1.0" - checksum: 10c0/a5135e589c3f1972b8877805f50a084a04865ccb1d68e5e1f3b94a8841b3485da4142e33413d8fd76bc0e6444531d3adf1f59f359c11ffac452b743d835068ab - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.27.1": - version: 7.27.4 - resolution: "@babel/traverse@npm:7.27.4" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.27.3" - "@babel/parser": "npm:^7.27.4" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.27.3" - debug: "npm:^4.3.1" - globals: "npm:^11.1.0" - checksum: 10c0/6de8aa2a0637a6ee6d205bf48b9e923928a02415771fdec60085ed754dcdf605e450bb3315c2552fa51c31a4662275b45d5ae4ad527ce55a7db9acebdbbbb8ed - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/traverse@npm:7.28.5" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.5" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.5" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.5" - debug: "npm:^4.3.1" - checksum: 10c0/f6c4a595993ae2b73f2d4cd9c062f2e232174d293edd4abe1d715bd6281da8d99e47c65857e8d0917d9384c65972f4acdebc6749a7c40a8fcc38b3c7fb3e706f - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/traverse@npm:7.28.3" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.3" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.3" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.2" - debug: "npm:^4.3.1" - checksum: 10c0/26e95b29a46925b7b41255e03185b7e65b2c4987e14bbee7bbf95867fb19c69181f301bbe1c7b201d4fe0cce6aa0cbea0282dad74b3a0fef3d9058f6c76fdcb3 - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/traverse@npm:7.28.4" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.3" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.4" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.4" - debug: "npm:^4.3.1" - checksum: 10c0/ee678fdd49c9f54a32e07e8455242390d43ce44887cea6567b233fe13907b89240c377e7633478a32c6cf1be0e17c2f7f3b0c59f0666e39c5074cc47b968489c - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/traverse@npm:7.29.0" - dependencies: - "@babel/code-frame": "npm:^7.29.0" - "@babel/generator": "npm:^7.29.0" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.29.0" - "@babel/template": "npm:^7.28.6" - "@babel/types": "npm:^7.29.0" - debug: "npm:^4.3.1" - checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb - languageName: node - linkType: hard - -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/types@npm:7.24.7" - dependencies: - "@babel/helper-string-parser": "npm:^7.24.7" - "@babel/helper-validator-identifier": "npm:^7.24.7" - to-fast-properties: "npm:^2.0.0" - checksum: 10c0/d9ecbfc3eb2b05fb1e6eeea546836ac30d990f395ef3fe3f75ced777a222c3cfc4489492f72e0ce3d9a5a28860a1ce5f81e66b88cf5088909068b3ff4fab72c1 - languageName: node - linkType: hard - -"@babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3": - version: 7.27.6 - resolution: "@babel/types@npm:7.27.6" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10c0/39d556be114f2a6d874ea25ad39826a9e3a0e98de0233ae6d932f6d09a4b222923a90a7274c635ed61f1ba49bbd345329226678800900ad1c8d11afabd573aaf - languageName: node - linkType: hard - -"@babel/types@npm:^7.28.2": - version: 7.28.2 - resolution: "@babel/types@npm:7.28.2" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10c0/24b11c9368e7e2c291fe3c1bcd1ed66f6593a3975f479cbb9dd7b8c8d8eab8a962b0d2fca616c043396ce82500ac7d23d594fbbbd013828182c01596370a0b10 - languageName: node - linkType: hard - -"@babel/types@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/types@npm:7.28.4" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10c0/ac6f909d6191319e08c80efbfac7bd9a25f80cc83b43cd6d82e7233f7a6b9d6e7b90236f3af7400a3f83b576895bcab9188a22b584eb0f224e80e6d4e95f4517 - languageName: node - linkType: hard - -"@babel/types@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/types@npm:7.28.5" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.28.5" - checksum: 10c0/a5a483d2100befbf125793640dec26b90b95fd233a94c19573325898a5ce1e52cdfa96e495c7dcc31b5eca5b66ce3e6d4a0f5a4a62daec271455959f208ab08a - languageName: node - linkType: hard - -"@babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/types@npm:7.29.0" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.28.5" - checksum: 10c0/23cc3466e83bcbfab8b9bd0edaafdb5d4efdb88b82b3be6728bbade5ba2f0996f84f63b1c5f7a8c0d67efded28300898a5f930b171bb40b311bca2029c4e9b4f - languageName: node - linkType: hard - -"@babel/types@npm:^7.8.3": - version: 7.26.0 - resolution: "@babel/types@npm:7.26.0" - dependencies: - "@babel/helper-string-parser": "npm:^7.25.9" - "@babel/helper-validator-identifier": "npm:^7.25.9" - checksum: 10c0/b694f41ad1597127e16024d766c33a641508aad037abd08d0d1f73af753e1119fa03b4a107d04b5f92cc19c095a594660547ae9bead1db2299212d644b0a5cb8 - languageName: node - linkType: hard - -"@emnapi/core@npm:1.10.0": - version: 1.10.0 - resolution: "@emnapi/core@npm:1.10.0" - dependencies: - "@emnapi/wasi-threads": "npm:1.2.1" - tslib: "npm:^2.4.0" - checksum: 10c0/f51d08227857b60632de7714d708124f0e100a1462dde6df8221760939aa3204a73193830371830fac0716f3ccd2129f2cac1b17cd7d7958bc4da9018a296edb - languageName: node - linkType: hard - -"@emnapi/runtime@npm:1.10.0": - version: 1.10.0 - resolution: "@emnapi/runtime@npm:1.10.0" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/953f14991d1aefb92ee6f8eb27dea725e484791a53a0cb5f47d9e0087b9a2c929ff2e92adf95af15d6ad456db6300c6b761ebf72b50a875b874a83520b3ba093 - languageName: node - linkType: hard - -"@emnapi/wasi-threads@npm:1.2.1": - version: 1.2.1 - resolution: "@emnapi/wasi-threads@npm:1.2.1" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/32fcfa81ab396533b2ec1f4082b1ff779a05d9c836bbbd3f4398405b0e6814c0d9503b7993130e37bc6941dbc1ded49f55e9700ae9ca4e803bab2b5bc5deb331 - languageName: node - linkType: hard - -"@esbuild/aix-ppc64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/aix-ppc64@npm:0.27.2" - conditions: os=aix & cpu=ppc64 - languageName: node - linkType: hard - -"@esbuild/android-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/android-arm64@npm:0.27.2" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/android-arm@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/android-arm@npm:0.27.2" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - -"@esbuild/android-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/android-x64@npm:0.27.2" - conditions: os=android & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/darwin-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/darwin-arm64@npm:0.27.2" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/darwin-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/darwin-x64@npm:0.27.2" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/freebsd-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/freebsd-arm64@npm:0.27.2" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/freebsd-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/freebsd-x64@npm:0.27.2" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/linux-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-arm64@npm:0.27.2" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/linux-arm@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-arm@npm:0.27.2" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@esbuild/linux-ia32@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-ia32@npm:0.27.2" - conditions: os=linux & cpu=ia32 - languageName: node - linkType: hard - -"@esbuild/linux-loong64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-loong64@npm:0.27.2" - conditions: os=linux & cpu=loong64 - languageName: node - linkType: hard - -"@esbuild/linux-mips64el@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-mips64el@npm:0.27.2" - conditions: os=linux & cpu=mips64el - languageName: node - linkType: hard - -"@esbuild/linux-ppc64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-ppc64@npm:0.27.2" - conditions: os=linux & cpu=ppc64 - languageName: node - linkType: hard - -"@esbuild/linux-riscv64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-riscv64@npm:0.27.2" - conditions: os=linux & cpu=riscv64 - languageName: node - linkType: hard - -"@esbuild/linux-s390x@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-s390x@npm:0.27.2" - conditions: os=linux & cpu=s390x - languageName: node - linkType: hard - -"@esbuild/linux-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-x64@npm:0.27.2" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/netbsd-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/netbsd-arm64@npm:0.27.2" - conditions: os=netbsd & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/netbsd-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/netbsd-x64@npm:0.27.2" - conditions: os=netbsd & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/openbsd-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/openbsd-arm64@npm:0.27.2" - conditions: os=openbsd & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/openbsd-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/openbsd-x64@npm:0.27.2" - conditions: os=openbsd & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/openharmony-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/openharmony-arm64@npm:0.27.2" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/sunos-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/sunos-x64@npm:0.27.2" - conditions: os=sunos & cpu=x64 - languageName: node - linkType: hard - -"@esbuild/win32-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/win32-arm64@npm:0.27.2" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@esbuild/win32-ia32@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/win32-ia32@npm:0.27.2" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - -"@esbuild/win32-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/win32-x64@npm:0.27.2" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@eslint-community/eslint-utils@npm:^4.8.0": - version: 4.9.0 - resolution: "@eslint-community/eslint-utils@npm:4.9.0" - dependencies: - eslint-visitor-keys: "npm:^3.4.3" - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/8881e22d519326e7dba85ea915ac7a143367c805e6ba1374c987aa2fbdd09195cc51183d2da72c0e2ff388f84363e1b220fd0d19bef10c272c63455162176817 - languageName: node - linkType: hard - -"@eslint-community/eslint-utils@npm:^4.9.1": - version: 4.9.1 - resolution: "@eslint-community/eslint-utils@npm:4.9.1" - dependencies: - eslint-visitor-keys: "npm:^3.4.3" - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/dc4ab5e3e364ef27e33666b11f4b86e1a6c1d7cbf16f0c6ff87b1619b3562335e9201a3d6ce806221887ff780ec9d828962a290bb910759fd40a674686503f02 - languageName: node - linkType: hard - -"@eslint-community/regexpp@npm:^4.12.2": - version: 4.12.2 - resolution: "@eslint-community/regexpp@npm:4.12.2" - checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d - languageName: node - linkType: hard - -"@eslint/config-array@npm:^0.23.5": - version: 0.23.5 - resolution: "@eslint/config-array@npm:0.23.5" - dependencies: - "@eslint/object-schema": "npm:^3.0.5" - debug: "npm:^4.3.1" - minimatch: "npm:^10.2.4" - checksum: 10c0/b24833c4c76e78ee075d306cd3f095db46b2db0f90cc13a6ee6e4275f9889731c05bf5403ab5fefb79c756e07ac9184ed0e04570341382f9eccbccc80e6d1a0c - languageName: node - linkType: hard - -"@eslint/config-helpers@npm:^0.5.5": - version: 0.5.5 - resolution: "@eslint/config-helpers@npm:0.5.5" - dependencies: - "@eslint/core": "npm:^1.2.1" - checksum: 10c0/18889c062cd6bdbd4cd92fe57318c44465ea66184aa0ba204a4420712c66764c64093a7905b6c2ffde23e51b268ca2cec1a39c605d336bebf17ee1ba4f0fc0bb - languageName: node - linkType: hard - -"@eslint/core@npm:^1.2.1": - version: 1.2.1 - resolution: "@eslint/core@npm:1.2.1" - dependencies: - "@types/json-schema": "npm:^7.0.15" - checksum: 10c0/10979b40588ecfef771fcb5013a542a35fb30692cc95a65f3481b0b36fbd89f5679efeb30d57f4eed35203d859aabace2a620177d6c536f71b299a1af2f3398f - languageName: node - linkType: hard - -"@eslint/object-schema@npm:^3.0.5": - version: 3.0.5 - resolution: "@eslint/object-schema@npm:3.0.5" - checksum: 10c0/1db337431f520b99e9edda64ef5fafd7ec6a029843eeb608753025125b6649d861d843cffafafd3c4e37926d7d5f9ec0c6a8e3665c13c3da2144e8132892e92e - languageName: node - linkType: hard - -"@eslint/plugin-kit@npm:^0.7.1": - version: 0.7.1 - resolution: "@eslint/plugin-kit@npm:0.7.1" - dependencies: - "@eslint/core": "npm:^1.2.1" - levn: "npm:^0.4.1" - checksum: 10c0/335b0c1c46fd906cb50bd5ce442b9cee18dc44342ce35c718ba4a63d1aa51d2797f16a517b2f4fe371ccd777b6862fafb2dc8195e00e69197ef4cb17ab32c01b - languageName: node - linkType: hard - -"@humanfs/core@npm:^0.19.1": - version: 0.19.1 - resolution: "@humanfs/core@npm:0.19.1" - checksum: 10c0/aa4e0152171c07879b458d0e8a704b8c3a89a8c0541726c6b65b81e84fd8b7564b5d6c633feadc6598307d34564bd53294b533491424e8e313d7ab6c7bc5dc67 - languageName: node - linkType: hard - -"@humanfs/node@npm:^0.16.6": - version: 0.16.6 - resolution: "@humanfs/node@npm:0.16.6" - dependencies: - "@humanfs/core": "npm:^0.19.1" - "@humanwhocodes/retry": "npm:^0.3.0" - checksum: 10c0/8356359c9f60108ec204cbd249ecd0356667359b2524886b357617c4a7c3b6aace0fd5a369f63747b926a762a88f8a25bc066fa1778508d110195ce7686243e1 - languageName: node - linkType: hard - -"@humanwhocodes/module-importer@npm:^1.0.1": - version: 1.0.1 - resolution: "@humanwhocodes/module-importer@npm:1.0.1" - checksum: 10c0/909b69c3b86d482c26b3359db16e46a32e0fb30bd306a3c176b8313b9e7313dba0f37f519de6aa8b0a1921349e505f259d19475e123182416a506d7f87e7f529 - languageName: node - linkType: hard - -"@humanwhocodes/retry@npm:^0.3.0": - version: 0.3.1 - resolution: "@humanwhocodes/retry@npm:0.3.1" - checksum: 10c0/f0da1282dfb45e8120480b9e2e275e2ac9bbe1cf016d046fdad8e27cc1285c45bb9e711681237944445157b430093412b4446c1ab3fc4bb037861b5904101d3b - languageName: node - linkType: hard - -"@humanwhocodes/retry@npm:^0.4.2": - version: 0.4.2 - resolution: "@humanwhocodes/retry@npm:0.4.2" - checksum: 10c0/0235525d38f243bee3bf8b25ed395fbf957fb51c08adae52787e1325673071abe856c7e18e530922ed2dd3ce12ed82ba01b8cee0279ac52a3315fcdc3a69ef0c - languageName: node - linkType: hard - -"@isaacs/cliui@npm:^8.0.2": - version: 8.0.2 - resolution: "@isaacs/cliui@npm:8.0.2" - dependencies: - string-width: "npm:^5.1.2" - string-width-cjs: "npm:string-width@^4.2.0" - strip-ansi: "npm:^7.0.1" - strip-ansi-cjs: "npm:strip-ansi@^6.0.1" - wrap-ansi: "npm:^8.1.0" - wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" - checksum: 10c0/b1bf42535d49f11dc137f18d5e4e63a28c5569de438a221c369483731e9dac9fb797af554e8bf02b6192d1e5eba6e6402cf93900c3d0ac86391d00d04876789e - languageName: node - linkType: hard - -"@joshwooding/vite-plugin-react-docgen-typescript@npm:^0.7.0": - version: 0.7.0 - resolution: "@joshwooding/vite-plugin-react-docgen-typescript@npm:0.7.0" - dependencies: - glob: "npm:^13.0.1" - react-docgen-typescript: "npm:^2.2.2" - peerDependencies: - typescript: ">= 4.3.x" - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/6d1a353e4dd0d9d641beafcf8d5c36805ad7f916ae07b817642033bc85c388f819f92dc94db192117dedfaa5d981ac5ef72911315e3e4bf2fe9e23d8956618e6 - languageName: node - linkType: hard - -"@jridgewell/gen-mapping@npm:^0.3.12": - version: 0.3.12 - resolution: "@jridgewell/gen-mapping@npm:0.3.12" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.0" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/32f771ae2467e4d440be609581f7338d786d3d621bac3469e943b9d6d116c23c4becb36f84898a92bbf2f3c0511365c54a945a3b86a83141547a2a360a5ec0c7 - languageName: node - linkType: hard - -"@jridgewell/gen-mapping@npm:^0.3.5": - version: 0.3.5 - resolution: "@jridgewell/gen-mapping@npm:0.3.5" - dependencies: - "@jridgewell/set-array": "npm:^1.2.1" - "@jridgewell/sourcemap-codec": "npm:^1.4.10" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/1be4fd4a6b0f41337c4f5fdf4afc3bd19e39c3691924817108b82ffcb9c9e609c273f936932b9fba4b3a298ce2eb06d9bff4eb1cc3bd81c4f4ee1b4917e25feb - languageName: node - linkType: hard - -"@jridgewell/remapping@npm:^2.3.5": - version: 2.3.5 - resolution: "@jridgewell/remapping@npm:2.3.5" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 - languageName: node - linkType: hard - -"@jridgewell/resolve-uri@npm:^3.1.0": - version: 3.1.2 - resolution: "@jridgewell/resolve-uri@npm:3.1.2" - checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e - languageName: node - linkType: hard - -"@jridgewell/set-array@npm:^1.2.1": - version: 1.2.1 - resolution: "@jridgewell/set-array@npm:1.2.1" - checksum: 10c0/2a5aa7b4b5c3464c895c802d8ae3f3d2b92fcbe84ad12f8d0bfbb1f5ad006717e7577ee1fd2eac00c088abe486c7adb27976f45d2941ff6b0b92b2c3302c60f4 - languageName: node - linkType: hard - -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15": - version: 1.4.15 - resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" - checksum: 10c0/0c6b5ae663087558039052a626d2d7ed5208da36cfd707dcc5cea4a07cfc918248403dcb5989a8f7afaf245ce0573b7cc6fd94c4a30453bd10e44d9363940ba5 - languageName: node - linkType: hard - -"@jridgewell/sourcemap-codec@npm:^1.5.0": - version: 1.5.4 - resolution: "@jridgewell/sourcemap-codec@npm:1.5.4" - checksum: 10c0/c5aab3e6362a8dd94ad80ab90845730c825fc4c8d9cf07ebca7a2eb8a832d155d62558800fc41d42785f989ddbb21db6df004d1786e8ecb65e428ab8dff71309 - languageName: node - linkType: hard - -"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": - version: 0.3.25 - resolution: "@jridgewell/trace-mapping@npm:0.3.25" - dependencies: - "@jridgewell/resolve-uri": "npm:^3.1.0" - "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10c0/3d1ce6ebc69df9682a5a8896b414c6537e428a1d68b02fcc8363b04284a8ca0df04d0ee3013132252ab14f2527bc13bea6526a912ecb5658f0e39fd2860b4df4 - languageName: node - linkType: hard - -"@jridgewell/trace-mapping@npm:^0.3.28": - version: 0.3.29 - resolution: "@jridgewell/trace-mapping@npm:0.3.29" - dependencies: - "@jridgewell/resolve-uri": "npm:^3.1.0" - "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10c0/fb547ba31658c4d74eb17e7389f4908bf7c44cef47acb4c5baa57289daf68e6fe53c639f41f751b3923aca67010501264f70e7b49978ad1f040294b22c37b333 - languageName: node - linkType: hard - -"@napi-rs/wasm-runtime@npm:^1.1.4": - version: 1.1.4 - resolution: "@napi-rs/wasm-runtime@npm:1.1.4" - dependencies: - "@tybys/wasm-util": "npm:^0.10.1" - peerDependencies: - "@emnapi/core": ^1.7.1 - "@emnapi/runtime": ^1.7.1 - checksum: 10c0/2e88e1955258949ccf2d18c79975821ad38071b465ef126a5e14110977b97868867b016c1ad046e963cccc42c0bd9db6c8ff5fd1ebb61b87bb3487f339041658 - languageName: node - linkType: hard - -"@npmcli/agent@npm:^2.0.0": - version: 2.2.2 - resolution: "@npmcli/agent@npm:2.2.2" - dependencies: - agent-base: "npm:^7.1.0" - http-proxy-agent: "npm:^7.0.0" - https-proxy-agent: "npm:^7.0.1" - lru-cache: "npm:^10.0.1" - socks-proxy-agent: "npm:^8.0.3" - checksum: 10c0/325e0db7b287d4154ecd164c0815c08007abfb07653cc57bceded17bb7fd240998a3cbdbe87d700e30bef494885eccc725ab73b668020811d56623d145b524ae - languageName: node - linkType: hard - -"@npmcli/fs@npm:^3.1.0": - version: 3.1.1 - resolution: "@npmcli/fs@npm:3.1.1" - dependencies: - semver: "npm:^7.3.5" - checksum: 10c0/c37a5b4842bfdece3d14dfdb054f73fe15ed2d3da61b34ff76629fb5b1731647c49166fd2a8bf8b56fcfa51200382385ea8909a3cbecdad612310c114d3f6c99 - languageName: node - linkType: hard - -"@oxc-project/types@npm:=0.127.0": - version: 0.127.0 - resolution: "@oxc-project/types@npm:0.127.0" - checksum: 10c0/52c0947ac64a9ca119fe971f947e784a35ecd14a072fa3f542a58a5f6c42010b53f2bf92731e39b9899b83c990a9517bbd29d1e5a5b7b489e52616685c6a9278 - languageName: node - linkType: hard - -"@pkgjs/parseargs@npm:^0.11.0": - version: 0.11.0 - resolution: "@pkgjs/parseargs@npm:0.11.0" - checksum: 10c0/5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd - languageName: node - linkType: hard - -"@rolldown/binding-android-arm64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.17" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-darwin-x64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.17" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17" - dependencies: - "@emnapi/core": "npm:1.10.0" - "@emnapi/runtime": "npm:1.10.0" - "@napi-rs/wasm-runtime": "npm:^1.1.4" - conditions: cpu=wasm32 - languageName: node - linkType: hard - -"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@rolldown/pluginutils@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/pluginutils@npm:1.0.0-rc.17" - checksum: 10c0/5e840b20cc531910c093c1ca36e550952cf4936465a50d89f0a98fc9d0dfd7d319d06a10a5f4376209d89e9bf4d60af6cc8363ebf0dcc5e60842f7fef438b2f0 - languageName: node - linkType: hard - -"@rolldown/pluginutils@npm:1.0.0-rc.3": - version: 1.0.0-rc.3 - resolution: "@rolldown/pluginutils@npm:1.0.0-rc.3" - checksum: 10c0/3928b6282a30f307d1b075d2f217180ae173ea9e00638ce46ab65f089bd5f7a0b2c488ae1ce530f509387793c656a2910337c4cd68fa9d37d7e439365989e699 - languageName: node - linkType: hard - -"@rollup/pluginutils@npm:^5.0.2": - version: 5.1.0 - resolution: "@rollup/pluginutils@npm:5.1.0" - dependencies: - "@types/estree": "npm:^1.0.0" - estree-walker: "npm:^2.0.2" - picomatch: "npm:^2.3.1" - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - checksum: 10c0/c7bed15711f942d6fdd3470fef4105b73991f99a478605e13d41888963330a6f9e32be37e6ddb13f012bc7673ff5e54f06f59fd47109436c1c513986a8a7612d - languageName: node - linkType: hard - -"@storybook/builder-vite@npm:10.3.6": - version: 10.3.6 - resolution: "@storybook/builder-vite@npm:10.3.6" - dependencies: - "@storybook/csf-plugin": "npm:10.3.6" - ts-dedent: "npm:^2.0.0" - peerDependencies: - storybook: ^10.3.6 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 10c0/f7e5c57362ba8df8dac4f71dc86d4117cd1edfaf2f61a3ecb0d6bece6b37357f1dc0bf135fd27b5552055b47e0fce5ffba67ffeb2fdedbc75fc986997062bf28 - languageName: node - linkType: hard - -"@storybook/components@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/components@npm:8.4.6" - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/1622b2f12b6d18e5c495a623deb2930888b3e8b173a271cbe42a7cbd6e14e80b736c57792ea97d5269dff0e6c0db40385d3ea80ab6e46d4cb6e104aee6cac6bc - languageName: node - linkType: hard - -"@storybook/csf-plugin@npm:10.3.6": - version: 10.3.6 - resolution: "@storybook/csf-plugin@npm:10.3.6" - dependencies: - unplugin: "npm:^2.3.5" - peerDependencies: - esbuild: "*" - rollup: "*" - storybook: ^10.3.6 - vite: "*" - webpack: "*" - peerDependenciesMeta: - esbuild: - optional: true - rollup: - optional: true - vite: - optional: true - webpack: - optional: true - checksum: 10c0/593fc6b9b6073c9dacd26f595ccb58f1ac28f3ab3cf63bf3d8d6913285765eb9c4fdb3e1c54356804ec8eccdde24f14cfa7af3e528731eb6d7e52266d81c4213 - languageName: node - linkType: hard - -"@storybook/global@npm:^5.0.0": - version: 5.0.0 - resolution: "@storybook/global@npm:5.0.0" - checksum: 10c0/8f1b61dcdd3a89584540896e659af2ecc700bc740c16909a7be24ac19127ea213324de144a141f7caf8affaed017d064fea0618d453afbe027cf60f54b4a6d0b - languageName: node - linkType: hard - -"@storybook/icons@npm:^2.0.1": - version: 2.0.1 - resolution: "@storybook/icons@npm:2.0.1" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/df2bbf1a5b50f12ab1bf78cae6de4dbf7c49df0e3a5f845553b51b20adbe8386a09fd172ea60342379f9284bb528cba2d0e2659cae6eb8d015cf92c8b32f1222 - languageName: node - linkType: hard - -"@storybook/manager-api@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/manager-api@npm:8.4.6" - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/5921ec72df0be765bd398aa906186c9b121a8b3415a7e1a10014a8d17c44aec386b59de3d240017bfc925be00c40a4da8d26991b5fa39023f23ba8efe1b0d58e - languageName: node - linkType: hard - -"@storybook/preview-api@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/preview-api@npm:8.4.6" - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/63967f4813c75e410634bff20189b5a670a061cfeeaa601ec07f0de82e2b4955af292836030d5a8432c3c7e48968285e121ed2bb55d2b5c70d17dbb4ada3c051 - languageName: node - linkType: hard - -"@storybook/react-dom-shim@npm:10.3.6": - version: 10.3.6 - resolution: "@storybook/react-dom-shim@npm:10.3.6" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.3.6 - checksum: 10c0/60183fc05b0410ca174f215d3271ffbccdc2680bd0704f58dc931cf60e72f497a9f22c00afa868398ad693c7ec633ef619541d4b0611fc2a577fb09609fcc3c7 - languageName: node - linkType: hard - -"@storybook/react-dom-shim@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/react-dom-shim@npm:8.4.6" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - checksum: 10c0/b97c6faa3adc3efe1b7b6f5e38476e040c0a988b14db68e368d704c68f3f4d4bf7866b36607c118a0483242921b34944b5f5f72614d9852476476f6ead462e5c - languageName: node - linkType: hard - -"@storybook/react-vite@npm:^10.3.6": - version: 10.3.6 - resolution: "@storybook/react-vite@npm:10.3.6" - dependencies: - "@joshwooding/vite-plugin-react-docgen-typescript": "npm:^0.7.0" - "@rollup/pluginutils": "npm:^5.0.2" - "@storybook/builder-vite": "npm:10.3.6" - "@storybook/react": "npm:10.3.6" - empathic: "npm:^2.0.0" - magic-string: "npm:^0.30.0" - react-docgen: "npm:^8.0.0" - resolve: "npm:^1.22.8" - tsconfig-paths: "npm:^4.2.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.3.6 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 10c0/3b939caf05985f0c6d5049c49b71fd8c7abac1f94f1559cfa135a755f3d94c0ef29bdd8d7f7f68c4676ea66852ed4b43d2e33d18434aa1c72f4aebca928b3e13 - languageName: node - linkType: hard - -"@storybook/react@npm:10.3.6": - version: 10.3.6 - resolution: "@storybook/react@npm:10.3.6" - dependencies: - "@storybook/global": "npm:^5.0.0" - "@storybook/react-dom-shim": "npm:10.3.6" - react-docgen: "npm:^8.0.2" - react-docgen-typescript: "npm:^2.2.2" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.3.6 - typescript: ">= 4.9.x" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/3d3af66105cdca98c537b4bcc3e92f87e3b56d154b24e9b167300e9d5de8a7ba6a5ef3c22203ccb6f4c2f0602a5546273b2b49728aa383ba0f98e66a286647a8 - languageName: node - linkType: hard - -"@storybook/react@npm:^8.4.6": - version: 8.4.6 - resolution: "@storybook/react@npm:8.4.6" - dependencies: - "@storybook/components": "npm:8.4.6" - "@storybook/global": "npm:^5.0.0" - "@storybook/manager-api": "npm:8.4.6" - "@storybook/preview-api": "npm:8.4.6" - "@storybook/react-dom-shim": "npm:8.4.6" - "@storybook/theming": "npm:8.4.6" - peerDependencies: - "@storybook/test": 8.4.6 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - typescript: ">= 4.2.x" - peerDependenciesMeta: - "@storybook/test": - optional: true - typescript: - optional: true - checksum: 10c0/1441f8ab3be91757647c6b1a05eb1ef0d78a454ffd14b01a14fdde00e92a8be8fc7c8408c4670b46bc20a5a04995514f0890e98ed6ee35c362ff36141da02f02 - languageName: node - linkType: hard - -"@storybook/theming@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/theming@npm:8.4.6" - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/7d9c8e5ef2c1d974cd5258301350a2345890326e7be7a5ed6bdd0db70fd1648c0bbb8ee1d905f8e66fa57b75c47aefe7ec9772ec0bfb9691d127dcc19286e4c9 - languageName: node - linkType: hard - -"@testing-library/jest-dom@npm:^6.9.1": - version: 6.9.1 - resolution: "@testing-library/jest-dom@npm:6.9.1" - dependencies: - "@adobe/css-tools": "npm:^4.4.0" - aria-query: "npm:^5.0.0" - css.escape: "npm:^1.5.1" - dom-accessibility-api: "npm:^0.6.3" - picocolors: "npm:^1.1.1" - redent: "npm:^3.0.0" - checksum: 10c0/4291ebd2f0f38d14cefac142c56c337941775a5807e2a3d6f1a14c2fbd6be76a18e498ed189e95bedc97d9e8cf1738049bc76c85b5bc5e23fae7c9e10f7b3a12 - languageName: node - linkType: hard - -"@testing-library/user-event@npm:^14.6.1": - version: 14.6.1 - resolution: "@testing-library/user-event@npm:14.6.1" - peerDependencies: - "@testing-library/dom": ">=7.21.4" - checksum: 10c0/75fea130a52bf320d35d46ed54f3eec77e71a56911b8b69a3fe29497b0b9947b2dc80d30f04054ad4ce7f577856ae3e5397ea7dff0ef14944d3909784c7a93fe - languageName: node - linkType: hard - -"@tybys/wasm-util@npm:^0.10.1": - version: 0.10.2 - resolution: "@tybys/wasm-util@npm:0.10.2" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/26165bcd1fd7269f42d7fbe3de318f854a8968de8397e89fc9a423bb3e2da35a52150f382e6323b3367595beb16d9800a6f35971a5599daf76da1742ec3afc25 - languageName: node - linkType: hard - -"@types/babel__core@npm:^7.18.0, @types/babel__core@npm:^7.20.5": - version: 7.20.5 - resolution: "@types/babel__core@npm:7.20.5" - dependencies: - "@babel/parser": "npm:^7.20.7" - "@babel/types": "npm:^7.20.7" - "@types/babel__generator": "npm:*" - "@types/babel__template": "npm:*" - "@types/babel__traverse": "npm:*" - checksum: 10c0/bdee3bb69951e833a4b811b8ee9356b69a61ed5b7a23e1a081ec9249769117fa83aaaf023bb06562a038eb5845155ff663e2d5c75dd95c1d5ccc91db012868ff - languageName: node - linkType: hard - -"@types/babel__generator@npm:*": - version: 7.6.8 - resolution: "@types/babel__generator@npm:7.6.8" - dependencies: - "@babel/types": "npm:^7.0.0" - checksum: 10c0/f0ba105e7d2296bf367d6e055bb22996886c114261e2cb70bf9359556d0076c7a57239d019dee42bb063f565bade5ccb46009bce2044b2952d964bf9a454d6d2 - languageName: node - linkType: hard - -"@types/babel__template@npm:*": - version: 7.4.4 - resolution: "@types/babel__template@npm:7.4.4" - dependencies: - "@babel/parser": "npm:^7.1.0" - "@babel/types": "npm:^7.0.0" - checksum: 10c0/cc84f6c6ab1eab1427e90dd2b76ccee65ce940b778a9a67be2c8c39e1994e6f5bbc8efa309f6cea8dc6754994524cd4d2896558df76d92e7a1f46ecffee7112b - languageName: node - linkType: hard - -"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.18.0": - version: 7.20.6 - resolution: "@types/babel__traverse@npm:7.20.6" - dependencies: - "@babel/types": "npm:^7.20.7" - checksum: 10c0/7ba7db61a53e28cac955aa99af280d2600f15a8c056619c05b6fc911cbe02c61aa4f2823299221b23ce0cce00b294c0e5f618ec772aa3f247523c2e48cf7b888 - languageName: node - linkType: hard - -"@types/babel__traverse@npm:^7.20.7": - version: 7.28.0 - resolution: "@types/babel__traverse@npm:7.28.0" - dependencies: - "@babel/types": "npm:^7.28.2" - checksum: 10c0/b52d7d4e8fc6a9018fe7361c4062c1c190f5778cf2466817cb9ed19d69fbbb54f9a85ffedeb748ed8062d2cf7d4cc088ee739848f47c57740de1c48cbf0d0994 - languageName: node - linkType: hard - -"@types/chai@npm:^5.2.2": - version: 5.2.2 - resolution: "@types/chai@npm:5.2.2" - dependencies: - "@types/deep-eql": "npm:*" - checksum: 10c0/49282bf0e8246800ebb36f17256f97bd3a8c4fb31f92ad3c0eaa7623518d7e87f1eaad4ad206960fcaf7175854bdff4cb167e4fe96811e0081b4ada83dd533ec - languageName: node - linkType: hard - -"@types/deep-eql@npm:*": - version: 4.0.2 - resolution: "@types/deep-eql@npm:4.0.2" - checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 - languageName: node - linkType: hard - -"@types/doctrine@npm:^0.0.9": - version: 0.0.9 - resolution: "@types/doctrine@npm:0.0.9" - checksum: 10c0/cdaca493f13c321cf0cacd1973efc0ae74569633145d9e6fc1128f32217a6968c33bea1f858275239fe90c98f3be57ec8f452b416a9ff48b8e8c1098b20fa51c - languageName: node - linkType: hard - -"@types/esrecurse@npm:^4.3.1": - version: 4.3.1 - resolution: "@types/esrecurse@npm:4.3.1" - checksum: 10c0/90dad74d5da3ad27606d8e8e757322f33171cfeaa15ad558b615cf71bb2a516492d18f55f4816384685a3eb2412142e732bbae9a4a7cd2cf3deb7572aa4ebe03 - languageName: node - linkType: hard - -"@types/estree@npm:^1.0.0": - version: 1.0.5 - resolution: "@types/estree@npm:1.0.5" - checksum: 10c0/b3b0e334288ddb407c7b3357ca67dbee75ee22db242ca7c56fe27db4e1a31989cb8af48a84dd401deb787fe10cc6b2ab1ee82dc4783be87ededbe3d53c79c70d - languageName: node - linkType: hard - -"@types/estree@npm:^1.0.6": - version: 1.0.6 - resolution: "@types/estree@npm:1.0.6" - checksum: 10c0/cdfd751f6f9065442cd40957c07fd80361c962869aa853c1c2fd03e101af8b9389d8ff4955a43a6fcfa223dd387a089937f95be0f3eec21ca527039fd2d9859a - languageName: node - linkType: hard - -"@types/estree@npm:^1.0.8": - version: 1.0.8 - resolution: "@types/estree@npm:1.0.8" - checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 - languageName: node - linkType: hard - -"@types/json-schema@npm:^7.0.15": - version: 7.0.15 - resolution: "@types/json-schema@npm:7.0.15" - checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db - languageName: node - linkType: hard - -"@types/prop-types@npm:*": - version: 15.7.12 - resolution: "@types/prop-types@npm:15.7.12" - checksum: 10c0/1babcc7db6a1177779f8fde0ccc78d64d459906e6ef69a4ed4dd6339c920c2e05b074ee5a92120fe4e9d9f1a01c952f843ebd550bee2332fc2ef81d1706878f8 - languageName: node - linkType: hard - -"@types/react-dom@npm:^18.3.1": - version: 18.3.1 - resolution: "@types/react-dom@npm:18.3.1" - dependencies: - "@types/react": "npm:*" - checksum: 10c0/8b416551c60bb6bd8ec10e198c957910cfb271bc3922463040b0d57cf4739cdcd24b13224f8d68f10318926e1ec3cd69af0af79f0291b599a992f8c80d47f1eb - languageName: node - linkType: hard - -"@types/react@npm:*": - version: 18.3.3 - resolution: "@types/react@npm:18.3.3" - dependencies: - "@types/prop-types": "npm:*" - csstype: "npm:^3.0.2" - checksum: 10c0/fe455f805c5da13b89964c3d68060cebd43e73ec15001a68b34634604a78140e6fc202f3f61679b9d809dde6d7a7c2cb3ed51e0fd1462557911db09879b55114 - languageName: node - linkType: hard - -"@types/react@npm:^18.3.13": - version: 18.3.13 - resolution: "@types/react@npm:18.3.13" - dependencies: - "@types/prop-types": "npm:*" - csstype: "npm:^3.0.2" - checksum: 10c0/91815e00157deb179fa670aa2dfc491952698b7743ffddca0e3e0f16e7a18454f3f5ef72321a07386c49e721563b9d280dbbdfae039face764e2fdd8ad949d4b - languageName: node - linkType: hard - -"@types/resolve@npm:^1.20.2": - version: 1.20.6 - resolution: "@types/resolve@npm:1.20.6" - checksum: 10c0/a9b0549d816ff2c353077365d865a33655a141d066d0f5a3ba6fd4b28bc2f4188a510079f7c1f715b3e7af505a27374adce2a5140a3ece2a059aab3d6e1a4244 - languageName: node - linkType: hard - -"@typescript-eslint/eslint-plugin@npm:^8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/eslint-plugin@npm:8.59.1" - dependencies: - "@eslint-community/regexpp": "npm:^4.12.2" - "@typescript-eslint/scope-manager": "npm:8.59.1" - "@typescript-eslint/type-utils": "npm:8.59.1" - "@typescript-eslint/utils": "npm:8.59.1" - "@typescript-eslint/visitor-keys": "npm:8.59.1" - ignore: "npm:^7.0.5" - natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.5.0" - peerDependencies: - "@typescript-eslint/parser": ^8.59.1 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/6dedd272d1aac960df74ab81e38bb4b398ac11b52118c69493a3aeecd15984c83bd4cae89df2e8362fbc2213f0a6d68c00d71dd53868fa1b5e1011290d4ea7b6 - languageName: node - linkType: hard - -"@typescript-eslint/parser@npm:^8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/parser@npm:8.59.1" - dependencies: - "@typescript-eslint/scope-manager": "npm:8.59.1" - "@typescript-eslint/types": "npm:8.59.1" - "@typescript-eslint/typescript-estree": "npm:8.59.1" - "@typescript-eslint/visitor-keys": "npm:8.59.1" - debug: "npm:^4.4.3" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/a20271b96e35fa5a8deea11ec40b30f7987daa5c3402e6e763e474517a25af20749a620490af159c2a65048065dea8a6d5fa3527ccc7a3716c2cd648a05ebc55 - languageName: node - linkType: hard - -"@typescript-eslint/project-service@npm:8.54.0": - version: 8.54.0 - resolution: "@typescript-eslint/project-service@npm:8.54.0" - dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.54.0" - "@typescript-eslint/types": "npm:^8.54.0" - debug: "npm:^4.4.3" - peerDependencies: - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/3392ae259199021a80616a44d9484d1c363f61bc5c631dff2d08c6a906c98716a20caa7b832b8970120a1eb1eb2de3ee890cd527d6edb04f532f4e48a690a792 - languageName: node - linkType: hard - -"@typescript-eslint/project-service@npm:8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/project-service@npm:8.59.1" - dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.59.1" - "@typescript-eslint/types": "npm:^8.59.1" - debug: "npm:^4.4.3" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/487e60e9696fbae11070fd0591a009c94b932af2a92d37a1a9d9f9eac5bbc2f56fef83f3d4e72349dfdaadf95473bb5fb7332eb13f9296b87b3f14e842f42747 - languageName: node - linkType: hard - -"@typescript-eslint/scope-manager@npm:8.54.0": - version: 8.54.0 - resolution: "@typescript-eslint/scope-manager@npm:8.54.0" - dependencies: - "@typescript-eslint/types": "npm:8.54.0" - "@typescript-eslint/visitor-keys": "npm:8.54.0" - checksum: 10c0/794740a5c0c1afc38d71e6bc59cc62870286e40d99f15e9760e76fb3d4197e961ee151c286c428535c404f5137721242a14da21350b749d0feb1f589f167814f - languageName: node - linkType: hard - -"@typescript-eslint/scope-manager@npm:8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/scope-manager@npm:8.59.1" - dependencies: - "@typescript-eslint/types": "npm:8.59.1" - "@typescript-eslint/visitor-keys": "npm:8.59.1" - checksum: 10c0/05c19039bde67691ad7a558ac61260639593ab0ffd8b73903b0f23c770aa3d79868bc8c1a11cdd5b0c8226e5dcef9ab1d679db46b5c5fe019541216170451614 - languageName: node - linkType: hard - -"@typescript-eslint/tsconfig-utils@npm:8.54.0, @typescript-eslint/tsconfig-utils@npm:^8.54.0": - version: 8.54.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.54.0" - peerDependencies: - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/e8598b0f051650c085d749002138d12249a3efd03e7de02e9e7913939dddd649d159b91f29ca3d28f5ee798b3f528a7195688e23c5e0b315d534e7af20a0c99a - languageName: node - linkType: hard - -"@typescript-eslint/tsconfig-utils@npm:8.59.1, @typescript-eslint/tsconfig-utils@npm:^8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.59.1" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/a3d123edbc39e7bfa3f58f722fe755787e71771d97b03ed80ea0706dcf3f25895e217e61b38049db1b05f246a26c6afb4e4a518bad21e7d1e71bb8dc136084ce - languageName: node - linkType: hard - -"@typescript-eslint/type-utils@npm:8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/type-utils@npm:8.59.1" - dependencies: - "@typescript-eslint/types": "npm:8.59.1" - "@typescript-eslint/typescript-estree": "npm:8.59.1" - "@typescript-eslint/utils": "npm:8.59.1" - debug: "npm:^4.4.3" - ts-api-utils: "npm:^2.5.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/c5f0f8e53f85ddf796a45b485937b7d5aef5c884fed412ff945392376166242658e4b431bd9633e1e08d6dba7e83b6125283e4866f5a9b4ae61fec355705122d - languageName: node - linkType: hard - -"@typescript-eslint/types@npm:8.54.0, @typescript-eslint/types@npm:^8.54.0": - version: 8.54.0 - resolution: "@typescript-eslint/types@npm:8.54.0" - checksum: 10c0/2219594fe5e8931ff91fd1b7a2606d33cd4f093d43f9ca71bcaa37f106ef79ad51f830dea51392f7e3d8bca77f7077ef98733f87bc008fad2f0bbd9ea5fb8a40 - languageName: node - linkType: hard - -"@typescript-eslint/types@npm:8.59.1, @typescript-eslint/types@npm:^8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/types@npm:8.59.1" - checksum: 10c0/a0bf98389e8673d4aa1034fdef9bb78f576b3dc6b8f413d4adf07ef6edff4a33fdb916148c3bac2cafdbf282c765eebf253c2a05edf3fda4123b8889921cd518 - languageName: node - linkType: hard - -"@typescript-eslint/typescript-estree@npm:8.54.0": - version: 8.54.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.54.0" - dependencies: - "@typescript-eslint/project-service": "npm:8.54.0" - "@typescript-eslint/tsconfig-utils": "npm:8.54.0" - "@typescript-eslint/types": "npm:8.54.0" - "@typescript-eslint/visitor-keys": "npm:8.54.0" - debug: "npm:^4.4.3" - minimatch: "npm:^9.0.5" - semver: "npm:^7.7.3" - tinyglobby: "npm:^0.2.15" - ts-api-utils: "npm:^2.4.0" - peerDependencies: - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/1a1a7c0a318e71f3547ab5573198d36165ea152c50447ef92e6326303f9a5c397606201ba80c7b86a725dcdd2913e924be94466a0c33b1b0c3ee852059e646b6 - languageName: node - linkType: hard - -"@typescript-eslint/typescript-estree@npm:8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/typescript-estree@npm:8.59.1" - dependencies: - "@typescript-eslint/project-service": "npm:8.59.1" - "@typescript-eslint/tsconfig-utils": "npm:8.59.1" - "@typescript-eslint/types": "npm:8.59.1" - "@typescript-eslint/visitor-keys": "npm:8.59.1" - debug: "npm:^4.4.3" - minimatch: "npm:^10.2.2" - semver: "npm:^7.7.3" - tinyglobby: "npm:^0.2.15" - ts-api-utils: "npm:^2.5.0" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/80b2624185d303741a710ba90e4fcb4e52320c1fc614f62cce785bfb39dfb9560ea5d325ff590d929c689b7dae7c28a598a26e1862477cc108c4ae4e8fe62c78 - languageName: node - linkType: hard - -"@typescript-eslint/utils@npm:8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/utils@npm:8.59.1" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.9.1" - "@typescript-eslint/scope-manager": "npm:8.59.1" - "@typescript-eslint/types": "npm:8.59.1" - "@typescript-eslint/typescript-estree": "npm:8.59.1" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/82a3fdb52d5f54622f8796eaeca508c630e65bfb94423645c1097b377fd56cf43b2999a83f11f42924e0cbb93b22faca6e572ee27cf550795b99e22193a0d41c - languageName: node - linkType: hard - -"@typescript-eslint/utils@npm:^8.48.0": - version: 8.54.0 - resolution: "@typescript-eslint/utils@npm:8.54.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.9.1" - "@typescript-eslint/scope-manager": "npm:8.54.0" - "@typescript-eslint/types": "npm:8.54.0" - "@typescript-eslint/typescript-estree": "npm:8.54.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/949a97dca8024d39666e04ecdf2d4e12722f5064c387901e72bdcc7adafb96cf650a070dc79f9dd46fa1aae6ac2b5eac5ae3fe5a6979385208c28809a1bd143f - languageName: node - linkType: hard - -"@typescript-eslint/visitor-keys@npm:8.54.0": - version: 8.54.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.54.0" - dependencies: - "@typescript-eslint/types": "npm:8.54.0" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/f83a9aa92f7f4d1fdb12cbca28c6f5704c36371264606b456388b2c869fc61e73c86d3736556e1bb6e253f3a607128b5b1bf6c68395800ca06f18705576faadd - languageName: node - linkType: hard - -"@typescript-eslint/visitor-keys@npm:8.59.1": - version: 8.59.1 - resolution: "@typescript-eslint/visitor-keys@npm:8.59.1" - dependencies: - "@typescript-eslint/types": "npm:8.59.1" - eslint-visitor-keys: "npm:^5.0.0" - checksum: 10c0/1144426dda53e855698301eae6301ae928785915225e6a775f0b51bf5d67b67e90def7b851e851ce76235cff3e1324132d03c7843a33ce2c4f0eb0764cc2b80a - languageName: node - linkType: hard - -"@vitejs/plugin-react@npm:^5.1.4": - version: 5.1.4 - resolution: "@vitejs/plugin-react@npm:5.1.4" - dependencies: - "@babel/core": "npm:^7.29.0" - "@babel/plugin-transform-react-jsx-self": "npm:^7.27.1" - "@babel/plugin-transform-react-jsx-source": "npm:^7.27.1" - "@rolldown/pluginutils": "npm:1.0.0-rc.3" - "@types/babel__core": "npm:^7.20.5" - react-refresh: "npm:^0.18.0" - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/dd7b8f40717ecd4a5ab18f467134ea8135f9a443359333d71e4114aeacfc8b679be9fd36dc12290d076c78883a02e708bfe1f0d93411c06c9659da0879b952e3 - languageName: node - linkType: hard - -"@vitest/expect@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/expect@npm:3.2.4" - dependencies: - "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - chai: "npm:^5.2.0" - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/7586104e3fd31dbe1e6ecaafb9a70131e4197dce2940f727b6a84131eee3decac7b10f9c7c72fa5edbdb68b6f854353bd4c0fa84779e274207fb7379563b10db - languageName: node - linkType: hard - -"@vitest/pretty-format@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/pretty-format@npm:3.2.4" - dependencies: - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/5ad7d4278e067390d7d633e307fee8103958806a419ca380aec0e33fae71b44a64415f7a9b4bc11635d3c13d4a9186111c581d3cef9c65cc317e68f077456887 - languageName: node - linkType: hard - -"@vitest/spy@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/spy@npm:3.2.4" - dependencies: - tinyspy: "npm:^4.0.3" - checksum: 10c0/6ebf0b4697dc238476d6b6a60c76ba9eb1dd8167a307e30f08f64149612fd50227682b876420e4c2e09a76334e73f72e3ebf0e350714dc22474258292e202024 - languageName: node - linkType: hard - -"@vitest/utils@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/utils@npm:3.2.4" - dependencies: - "@vitest/pretty-format": "npm:3.2.4" - loupe: "npm:^3.1.4" - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/024a9b8c8bcc12cf40183c246c244b52ecff861c6deb3477cbf487ac8781ad44c68a9c5fd69f8c1361878e55b97c10d99d511f2597f1f7244b5e5101d028ba64 - languageName: node - linkType: hard - -"@webcontainer/env@npm:^1.1.1": - version: 1.1.1 - resolution: "@webcontainer/env@npm:1.1.1" - checksum: 10c0/bc64114ffa7ee92f4985cc2bdd5e27f6f31d892b9aa5cde68eaf93df02d13ee6edf13faeebdd701464183b6f8f9c47c14975958cdd6fc20e7356ad32f6ee39e7 - languageName: node - linkType: hard - -"abbrev@npm:^2.0.0": - version: 2.0.0 - resolution: "abbrev@npm:2.0.0" - checksum: 10c0/f742a5a107473946f426c691c08daba61a1d15942616f300b5d32fd735be88fef5cba24201757b6c407fd564555fb48c751cfa33519b2605c8a7aadd22baf372 - languageName: node - linkType: hard - -"acorn-jsx@npm:^5.3.2": - version: 5.3.2 - resolution: "acorn-jsx@npm:5.3.2" - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 10c0/4c54868fbef3b8d58927d5e33f0a4de35f59012fe7b12cf9dfbb345fb8f46607709e1c4431be869a23fb63c151033d84c4198fa9f79385cec34fcb1dd53974c1 - languageName: node - linkType: hard - -"acorn@npm:^8.15.0": - version: 8.15.0 - resolution: "acorn@npm:8.15.0" - bin: - acorn: bin/acorn - checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec - languageName: node - linkType: hard - -"acorn@npm:^8.16.0": - version: 8.16.0 - resolution: "acorn@npm:8.16.0" - bin: - acorn: bin/acorn - checksum: 10c0/c9c52697227661b68d0debaf972222d4f622aa06b185824164e153438afa7b08273432ca43ea792cadb24dada1d46f6f6bb1ef8de9956979288cc1b96bf9914e - languageName: node - linkType: hard - -"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1": - version: 7.1.1 - resolution: "agent-base@npm:7.1.1" - dependencies: - debug: "npm:^4.3.4" - checksum: 10c0/e59ce7bed9c63bf071a30cc471f2933862044c97fd9958967bfe22521d7a0f601ce4ed5a8c011799d0c726ca70312142ae193bbebb60f576b52be19d4a363b50 - languageName: node - linkType: hard - -"aggregate-error@npm:^3.0.0": - version: 3.1.0 - resolution: "aggregate-error@npm:3.1.0" - dependencies: - clean-stack: "npm:^2.0.0" - indent-string: "npm:^4.0.0" - checksum: 10c0/a42f67faa79e3e6687a4923050e7c9807db3848a037076f791d10e092677d65c1d2d863b7848560699f40fc0502c19f40963fb1cd1fb3d338a7423df8e45e039 - languageName: node - linkType: hard - -"ajv@npm:^6.14.0": - version: 6.14.0 - resolution: "ajv@npm:6.14.0" - dependencies: - fast-deep-equal: "npm:^3.1.1" - fast-json-stable-stringify: "npm:^2.0.0" - json-schema-traverse: "npm:^0.4.1" - uri-js: "npm:^4.2.2" - checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 - languageName: node - linkType: hard - -"ansi-regex@npm:^5.0.1": - version: 5.0.1 - resolution: "ansi-regex@npm:5.0.1" - checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 - languageName: node - linkType: hard - -"ansi-regex@npm:^6.0.1": - version: 6.0.1 - resolution: "ansi-regex@npm:6.0.1" - checksum: 10c0/cbe16dbd2c6b2735d1df7976a7070dd277326434f0212f43abf6d87674095d247968209babdaad31bb00882fa68807256ba9be340eec2f1004de14ca75f52a08 - languageName: node - linkType: hard - -"ansi-styles@npm:^3.2.1": - version: 3.2.1 - resolution: "ansi-styles@npm:3.2.1" - dependencies: - color-convert: "npm:^1.9.0" - checksum: 10c0/ece5a8ef069fcc5298f67e3f4771a663129abd174ea2dfa87923a2be2abf6cd367ef72ac87942da00ce85bd1d651d4cd8595aebdb1b385889b89b205860e977b - languageName: node - linkType: hard - -"ansi-styles@npm:^4.0.0": - version: 4.3.0 - resolution: "ansi-styles@npm:4.3.0" - dependencies: - color-convert: "npm:^2.0.1" - checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 - languageName: node - linkType: hard - -"ansi-styles@npm:^6.1.0": - version: 6.2.1 - resolution: "ansi-styles@npm:6.2.1" - checksum: 10c0/5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c - languageName: node - linkType: hard - -"aria-query@npm:^5.0.0": - version: 5.3.2 - resolution: "aria-query@npm:5.3.2" - checksum: 10c0/003c7e3e2cff5540bf7a7893775fc614de82b0c5dde8ae823d47b7a28a9d4da1f7ed85f340bdb93d5649caa927755f0e31ecc7ab63edfdfc00c8ef07e505e03e - languageName: node - linkType: hard - -"assertion-error@npm:^2.0.1": - version: 2.0.1 - resolution: "assertion-error@npm:2.0.1" - checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 - languageName: node - linkType: hard - -"ast-types@npm:^0.16.1": - version: 0.16.1 - resolution: "ast-types@npm:0.16.1" - dependencies: - tslib: "npm:^2.0.1" - checksum: 10c0/abcc49e42eb921a7ebc013d5bec1154651fb6dbc3f497541d488859e681256901b2990b954d530ba0da4d0851271d484f7057d5eff5e07cb73e8b10909f711bf - languageName: node - linkType: hard - -"balanced-match@npm:^1.0.0": - version: 1.0.2 - resolution: "balanced-match@npm:1.0.2" - checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee - languageName: node - linkType: hard - -"balanced-match@npm:^4.0.2": - version: 4.0.4 - resolution: "balanced-match@npm:4.0.4" - checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b - languageName: node - linkType: hard - -"brace-expansion@npm:^2.0.2": - version: 2.0.2 - resolution: "brace-expansion@npm:2.0.2" - dependencies: - balanced-match: "npm:^1.0.0" - checksum: 10c0/6d117a4c793488af86b83172deb6af143e94c17bc53b0b3cec259733923b4ca84679d506ac261f4ba3c7ed37c46018e2ff442f9ce453af8643ecd64f4a54e6cf - languageName: node - linkType: hard - -"brace-expansion@npm:^5.0.2": - version: 5.0.4 - resolution: "brace-expansion@npm:5.0.4" - dependencies: - balanced-match: "npm:^4.0.2" - checksum: 10c0/359cbcfa80b2eb914ca1f3440e92313fbfe7919ee6b274c35db55bec555aded69dac5ee78f102cec90c35f98c20fa43d10936d0cd9978158823c249257e1643a - languageName: node - linkType: hard - -"brace-expansion@npm:^5.0.5": - version: 5.0.5 - resolution: "brace-expansion@npm:5.0.5" - dependencies: - balanced-match: "npm:^4.0.2" - checksum: 10c0/4d238e14ed4f5cc9c07285550a41cef23121ca08ba99fa9eb5b55b580dcb6bf868b8210aa10526bdc9f8dc97f33ca2a7259039c4cc131a93042beddb424c48e3 - languageName: node - linkType: hard - -"browserslist@npm:^4.22.2": - version: 4.23.1 - resolution: "browserslist@npm:4.23.1" - dependencies: - caniuse-lite: "npm:^1.0.30001629" - electron-to-chromium: "npm:^1.4.796" - node-releases: "npm:^2.0.14" - update-browserslist-db: "npm:^1.0.16" - bin: - browserslist: cli.js - checksum: 10c0/eb47c7ab9d60db25ce2faca70efeb278faa7282a2f62b7f2fa2f92e5f5251cf65144244566c86559419ff4f6d78f59ea50e39911321ad91f3b27788901f1f5e9 - languageName: node - linkType: hard - -"browserslist@npm:^4.24.0": - version: 4.24.2 - resolution: "browserslist@npm:4.24.2" - dependencies: - caniuse-lite: "npm:^1.0.30001669" - electron-to-chromium: "npm:^1.5.41" - node-releases: "npm:^2.0.18" - update-browserslist-db: "npm:^1.1.1" - bin: - browserslist: cli.js - checksum: 10c0/d747c9fb65ed7b4f1abcae4959405707ed9a7b835639f8a9ba0da2911995a6ab9b0648fd05baf2a4d4e3cf7f9fdbad56d3753f91881e365992c1d49c8d88ff7a - languageName: node - linkType: hard - -"bundle-name@npm:^4.1.0": - version: 4.1.0 - resolution: "bundle-name@npm:4.1.0" - dependencies: - run-applescript: "npm:^7.0.0" - checksum: 10c0/8e575981e79c2bcf14d8b1c027a3775c095d362d1382312f444a7c861b0e21513c0bd8db5bd2b16e50ba0709fa622d4eab6b53192d222120305e68359daece29 - languageName: node - linkType: hard - -"cacache@npm:^18.0.0": - version: 18.0.4 - resolution: "cacache@npm:18.0.4" - dependencies: - "@npmcli/fs": "npm:^3.1.0" - fs-minipass: "npm:^3.0.0" - glob: "npm:^10.2.2" - lru-cache: "npm:^10.0.1" - minipass: "npm:^7.0.3" - minipass-collect: "npm:^2.0.1" - minipass-flush: "npm:^1.0.5" - minipass-pipeline: "npm:^1.2.4" - p-map: "npm:^4.0.0" - ssri: "npm:^10.0.0" - tar: "npm:^6.1.11" - unique-filename: "npm:^3.0.0" - checksum: 10c0/6c055bafed9de4f3dcc64ac3dc7dd24e863210902b7c470eb9ce55a806309b3efff78033e3d8b4f7dcc5d467f2db43c6a2857aaaf26f0094b8a351d44c42179f - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001629": - version: 1.0.30001629 - resolution: "caniuse-lite@npm:1.0.30001629" - checksum: 10c0/e95136a423c0c5e7f9d026ef3f9be8d06cadc4c83ad65eedfaeaba6b5eb814489ea186e90bae1085f3be7348577e25f8fe436b384c2f983324ad8dea4a7dfe1d - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001669": - version: 1.0.30001684 - resolution: "caniuse-lite@npm:1.0.30001684" - checksum: 10c0/446485ca3d9caf408a339a44636a86a2b119ec247492393ae661cd93dccd6668401dd2dfec1e149be4e44563cd1e23351b44453a52fa2c2f19e2bf3287c865f6 - languageName: node - linkType: hard - -"chai@npm:^5.2.0": - version: 5.2.0 - resolution: "chai@npm:5.2.0" - dependencies: - assertion-error: "npm:^2.0.1" - check-error: "npm:^2.1.1" - deep-eql: "npm:^5.0.1" - loupe: "npm:^3.1.0" - pathval: "npm:^2.0.0" - checksum: 10c0/dfd1cb719c7cebb051b727672d382a35338af1470065cb12adb01f4ee451bbf528e0e0f9ab2016af5fc1eea4df6e7f4504dc8443f8f00bd8fb87ad32dc516f7d - languageName: node - linkType: hard - -"chalk@npm:^2.4.2": - version: 2.4.2 - resolution: "chalk@npm:2.4.2" - dependencies: - ansi-styles: "npm:^3.2.1" - escape-string-regexp: "npm:^1.0.5" - supports-color: "npm:^5.3.0" - checksum: 10c0/e6543f02ec877732e3a2d1c3c3323ddb4d39fbab687c23f526e25bd4c6a9bf3b83a696e8c769d078e04e5754921648f7821b2a2acfd16c550435fd630026e073 - languageName: node - linkType: hard - -"check-error@npm:^2.1.1": - version: 2.1.1 - resolution: "check-error@npm:2.1.1" - checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e - languageName: node - linkType: hard - -"chownr@npm:^2.0.0": - version: 2.0.0 - resolution: "chownr@npm:2.0.0" - checksum: 10c0/594754e1303672171cc04e50f6c398ae16128eb134a88f801bf5354fd96f205320f23536a045d9abd8b51024a149696e51231565891d4efdab8846021ecf88e6 - languageName: node - linkType: hard - -"clean-stack@npm:^2.0.0": - version: 2.2.0 - resolution: "clean-stack@npm:2.2.0" - checksum: 10c0/1f90262d5f6230a17e27d0c190b09d47ebe7efdd76a03b5a1127863f7b3c9aec4c3e6c8bb3a7bbf81d553d56a1fd35728f5a8ef4c63f867ac8d690109742a8c1 - languageName: node - linkType: hard - -"color-convert@npm:^1.9.0": - version: 1.9.3 - resolution: "color-convert@npm:1.9.3" - dependencies: - color-name: "npm:1.1.3" - checksum: 10c0/5ad3c534949a8c68fca8fbc6f09068f435f0ad290ab8b2f76841b9e6af7e0bb57b98cb05b0e19fe33f5d91e5a8611ad457e5f69e0a484caad1f7487fd0e8253c - languageName: node - linkType: hard - -"color-convert@npm:^2.0.1": - version: 2.0.1 - resolution: "color-convert@npm:2.0.1" - dependencies: - color-name: "npm:~1.1.4" - checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 - languageName: node - linkType: hard - -"color-name@npm:1.1.3": - version: 1.1.3 - resolution: "color-name@npm:1.1.3" - checksum: 10c0/566a3d42cca25b9b3cd5528cd7754b8e89c0eb646b7f214e8e2eaddb69994ac5f0557d9c175eb5d8f0ad73531140d9c47525085ee752a91a2ab15ab459caf6d6 - languageName: node - linkType: hard - -"color-name@npm:~1.1.4": - version: 1.1.4 - resolution: "color-name@npm:1.1.4" - checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 - languageName: node - linkType: hard - -"convert-source-map@npm:^2.0.0": - version: 2.0.0 - resolution: "convert-source-map@npm:2.0.0" - checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b - languageName: node - linkType: hard - -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.6": - version: 7.0.6 - resolution: "cross-spawn@npm:7.0.6" - dependencies: - path-key: "npm:^3.1.0" - shebang-command: "npm:^2.0.0" - which: "npm:^2.0.1" - checksum: 10c0/053ea8b2135caff68a9e81470e845613e374e7309a47731e81639de3eaeb90c3d01af0e0b44d2ab9d50b43467223b88567dfeb3262db942dc063b9976718ffc1 - languageName: node - linkType: hard - -"css.escape@npm:^1.5.1": - version: 1.5.1 - resolution: "css.escape@npm:1.5.1" - checksum: 10c0/5e09035e5bf6c2c422b40c6df2eb1529657a17df37fda5d0433d722609527ab98090baf25b13970ca754079a0f3161dd3dfc0e743563ded8cfa0749d861c1525 - languageName: node - linkType: hard - -"csstype@npm:^3.0.2": - version: 3.1.3 - resolution: "csstype@npm:3.1.3" - checksum: 10c0/80c089d6f7e0c5b2bd83cf0539ab41474198579584fa10d86d0cafe0642202343cbc119e076a0b1aece191989477081415d66c9fefbf3c957fc2fc4b7009f248 - languageName: node - linkType: hard - -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4": - version: 4.3.5 - resolution: "debug@npm:4.3.5" - dependencies: - ms: "npm:2.1.2" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/082c375a2bdc4f4469c99f325ff458adad62a3fc2c482d59923c260cb08152f34e2659f72b3767db8bb2f21ca81a60a42d1019605a412132d7b9f59363a005cc - languageName: node - linkType: hard - -"debug@npm:^4.4.3": - version: 4.4.3 - resolution: "debug@npm:4.4.3" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 - languageName: node - linkType: hard - -"deep-eql@npm:^5.0.1": - version: 5.0.2 - resolution: "deep-eql@npm:5.0.2" - checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 - languageName: node - linkType: hard - -"deep-is@npm:^0.1.3": - version: 0.1.4 - resolution: "deep-is@npm:0.1.4" - checksum: 10c0/7f0ee496e0dff14a573dc6127f14c95061b448b87b995fc96c017ce0a1e66af1675e73f1d6064407975bc4ea6ab679497a29fff7b5b9c4e99cb10797c1ad0b4c - languageName: node - linkType: hard - -"default-browser-id@npm:^5.0.0": - version: 5.0.1 - resolution: "default-browser-id@npm:5.0.1" - checksum: 10c0/5288b3094c740ef3a86df9b999b04ff5ba4dee6b64e7b355c0fff5217752c8c86908d67f32f6cba9bb4f9b7b61a1b640c0a4f9e34c57e0ff3493559a625245ee - languageName: node - linkType: hard - -"default-browser@npm:^5.2.1": - version: 5.4.0 - resolution: "default-browser@npm:5.4.0" - dependencies: - bundle-name: "npm:^4.1.0" - default-browser-id: "npm:^5.0.0" - checksum: 10c0/a49ddd0c7b1a319163f64a5fc68ebb45a98548ea23a3155e04518f026173d85cfa2f451b646366c36c8f70b01e4cb773e23d1d22d2c61d8b84e5fbf151b4b609 - languageName: node - linkType: hard - -"define-lazy-prop@npm:^3.0.0": - version: 3.0.0 - resolution: "define-lazy-prop@npm:3.0.0" - checksum: 10c0/5ab0b2bf3fa58b3a443140bbd4cd3db1f91b985cc8a246d330b9ac3fc0b6a325a6d82bddc0b055123d745b3f9931afeea74a5ec545439a1630b9c8512b0eeb49 - languageName: node - linkType: hard - -"detect-libc@npm:^2.0.3": - version: 2.1.2 - resolution: "detect-libc@npm:2.1.2" - checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 - languageName: node - linkType: hard - -"doctrine@npm:^3.0.0": - version: 3.0.0 - resolution: "doctrine@npm:3.0.0" - dependencies: - esutils: "npm:^2.0.2" - checksum: 10c0/c96bdccabe9d62ab6fea9399fdff04a66e6563c1d6fb3a3a063e8d53c3bb136ba63e84250bbf63d00086a769ad53aef92d2bd483f03f837fc97b71cbee6b2520 - languageName: node - linkType: hard - -"dom-accessibility-api@npm:^0.6.3": - version: 0.6.3 - resolution: "dom-accessibility-api@npm:0.6.3" - checksum: 10c0/10bee5aa514b2a9a37c87cd81268db607a2e933a050074abc2f6fa3da9080ebed206a320cbc123567f2c3087d22292853bdfdceaffdd4334ffe2af9510b29360 - languageName: node - linkType: hard - -"eastasianwidth@npm:^0.2.0": - version: 0.2.0 - resolution: "eastasianwidth@npm:0.2.0" - checksum: 10c0/26f364ebcdb6395f95124fda411f63137a4bfb5d3a06453f7f23dfe52502905bd84e0488172e0f9ec295fdc45f05c23d5d91baf16bd26f0fe9acd777a188dc39 - languageName: node - linkType: hard - -"electron-to-chromium@npm:^1.4.796": - version: 1.4.796 - resolution: "electron-to-chromium@npm:1.4.796" - checksum: 10c0/4f80f06f8e86a56889c1f687db4fec2d5cba6daf23e1f5f621e98254501579d83eaeff9aa1f7aa4144407b519507ea1e55397bfa8f82f1491b17cc4c238bdf6e - languageName: node - linkType: hard - -"electron-to-chromium@npm:^1.5.41": - version: 1.5.67 - resolution: "electron-to-chromium@npm:1.5.67" - checksum: 10c0/bcd21c3961267fd733973586045a38d41f697e6821e7624cdd39d48fd744d9bd93ec7db59abbafeb464861218b959a920892cfaa719bff4441d1d49f8dcdff94 - languageName: node - linkType: hard - -"emoji-regex@npm:^8.0.0": - version: 8.0.0 - resolution: "emoji-regex@npm:8.0.0" - checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010 - languageName: node - linkType: hard - -"emoji-regex@npm:^9.2.2": - version: 9.2.2 - resolution: "emoji-regex@npm:9.2.2" - checksum: 10c0/af014e759a72064cf66e6e694a7fc6b0ed3d8db680427b021a89727689671cefe9d04151b2cad51dbaf85d5ba790d061cd167f1cf32eb7b281f6368b3c181639 - languageName: node - linkType: hard - -"empathic@npm:^2.0.0": - version: 2.0.0 - resolution: "empathic@npm:2.0.0" - checksum: 10c0/7d3b14b04a93b35c47bcc950467ec914fd241cd9acc0269b0ea160f13026ec110f520c90fae64720fde72cc1757b57f3f292fb606617b7fccac1f4d008a76506 - languageName: node - linkType: hard - -"encoding@npm:^0.1.13": - version: 0.1.13 - resolution: "encoding@npm:0.1.13" - dependencies: - iconv-lite: "npm:^0.6.2" - checksum: 10c0/36d938712ff00fe1f4bac88b43bcffb5930c1efa57bbcdca9d67e1d9d6c57cfb1200fb01efe0f3109b2ce99b231f90779532814a81370a1bd3274a0f58585039 - languageName: node - linkType: hard - -"env-paths@npm:^2.2.0": - version: 2.2.1 - resolution: "env-paths@npm:2.2.1" - checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 - languageName: node - linkType: hard - -"err-code@npm:^2.0.2": - version: 2.0.3 - resolution: "err-code@npm:2.0.3" - checksum: 10c0/b642f7b4dd4a376e954947550a3065a9ece6733ab8e51ad80db727aaae0817c2e99b02a97a3d6cecc648a97848305e728289cf312d09af395403a90c9d4d8a66 - languageName: node - linkType: hard - -"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0": - version: 0.27.2 - resolution: "esbuild@npm:0.27.2" - dependencies: - "@esbuild/aix-ppc64": "npm:0.27.2" - "@esbuild/android-arm": "npm:0.27.2" - "@esbuild/android-arm64": "npm:0.27.2" - "@esbuild/android-x64": "npm:0.27.2" - "@esbuild/darwin-arm64": "npm:0.27.2" - "@esbuild/darwin-x64": "npm:0.27.2" - "@esbuild/freebsd-arm64": "npm:0.27.2" - "@esbuild/freebsd-x64": "npm:0.27.2" - "@esbuild/linux-arm": "npm:0.27.2" - "@esbuild/linux-arm64": "npm:0.27.2" - "@esbuild/linux-ia32": "npm:0.27.2" - "@esbuild/linux-loong64": "npm:0.27.2" - "@esbuild/linux-mips64el": "npm:0.27.2" - "@esbuild/linux-ppc64": "npm:0.27.2" - "@esbuild/linux-riscv64": "npm:0.27.2" - "@esbuild/linux-s390x": "npm:0.27.2" - "@esbuild/linux-x64": "npm:0.27.2" - "@esbuild/netbsd-arm64": "npm:0.27.2" - "@esbuild/netbsd-x64": "npm:0.27.2" - "@esbuild/openbsd-arm64": "npm:0.27.2" - "@esbuild/openbsd-x64": "npm:0.27.2" - "@esbuild/openharmony-arm64": "npm:0.27.2" - "@esbuild/sunos-x64": "npm:0.27.2" - "@esbuild/win32-arm64": "npm:0.27.2" - "@esbuild/win32-ia32": "npm:0.27.2" - "@esbuild/win32-x64": "npm:0.27.2" - dependenciesMeta: - "@esbuild/aix-ppc64": - optional: true - "@esbuild/android-arm": - optional: true - "@esbuild/android-arm64": - optional: true - "@esbuild/android-x64": - optional: true - "@esbuild/darwin-arm64": - optional: true - "@esbuild/darwin-x64": - optional: true - "@esbuild/freebsd-arm64": - optional: true - "@esbuild/freebsd-x64": - optional: true - "@esbuild/linux-arm": - optional: true - "@esbuild/linux-arm64": - optional: true - "@esbuild/linux-ia32": - optional: true - "@esbuild/linux-loong64": - optional: true - "@esbuild/linux-mips64el": - optional: true - "@esbuild/linux-ppc64": - optional: true - "@esbuild/linux-riscv64": - optional: true - "@esbuild/linux-s390x": - optional: true - "@esbuild/linux-x64": - optional: true - "@esbuild/netbsd-arm64": - optional: true - "@esbuild/netbsd-x64": - optional: true - "@esbuild/openbsd-arm64": - optional: true - "@esbuild/openbsd-x64": - optional: true - "@esbuild/openharmony-arm64": - optional: true - "@esbuild/sunos-x64": - optional: true - "@esbuild/win32-arm64": - optional: true - "@esbuild/win32-ia32": - optional: true - "@esbuild/win32-x64": - optional: true - bin: - esbuild: bin/esbuild - checksum: 10c0/cf83f626f55500f521d5fe7f4bc5871bec240d3deb2a01fbd379edc43b3664d1167428738a5aad8794b35d1cca985c44c375b1cd38a2ca613c77ced2c83aafcd - languageName: node - linkType: hard - -"escalade@npm:^3.1.2": - version: 3.1.2 - resolution: "escalade@npm:3.1.2" - checksum: 10c0/6b4adafecd0682f3aa1cd1106b8fff30e492c7015b178bc81b2d2f75106dabea6c6d6e8508fc491bd58e597c74abb0e8e2368f943ecb9393d4162e3c2f3cf287 - languageName: node - linkType: hard - -"escalade@npm:^3.2.0": - version: 3.2.0 - resolution: "escalade@npm:3.2.0" - checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 - languageName: node - linkType: hard - -"escape-string-regexp@npm:^1.0.5": - version: 1.0.5 - resolution: "escape-string-regexp@npm:1.0.5" - checksum: 10c0/a968ad453dd0c2724e14a4f20e177aaf32bb384ab41b674a8454afe9a41c5e6fe8903323e0a1052f56289d04bd600f81278edf140b0fcc02f5cac98d0f5b5371 - languageName: node - linkType: hard - -"escape-string-regexp@npm:^4.0.0": - version: 4.0.0 - resolution: "escape-string-regexp@npm:4.0.0" - checksum: 10c0/9497d4dd307d845bd7f75180d8188bb17ea8c151c1edbf6b6717c100e104d629dc2dfb687686181b0f4b7d732c7dfdc4d5e7a8ff72de1b0ca283a75bbb3a9cd9 - languageName: node - linkType: hard - -"eslint-plugin-react-hooks@npm:^7.1.1": - version: 7.1.1 - resolution: "eslint-plugin-react-hooks@npm:7.1.1" - dependencies: - "@babel/core": "npm:^7.24.4" - "@babel/parser": "npm:^7.24.4" - hermes-parser: "npm:^0.25.1" - zod: "npm:^3.25.0 || ^4.0.0" - zod-validation-error: "npm:^3.5.0 || ^4.0.0" - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - checksum: 10c0/cee8454915d71ac5d70a0d8f4f260e76eaf45fcd4162747dd4282b792ee5616d187351dabe6cdcff9040c79d0cec625635c4fd0777276be119efa88ebe058525 - languageName: node - linkType: hard - -"eslint-plugin-react-refresh@npm:^0.5.2": - version: 0.5.2 - resolution: "eslint-plugin-react-refresh@npm:0.5.2" - peerDependencies: - eslint: ^9 || ^10 - checksum: 10c0/6e5b1b8ad673535ea1134fa16ecda986c389a045b87ca935e6c5f69070b1889218f3116bfb8b793ec10f37c286f28904d0b5b1d62a76760e465aa32e73e6010e - languageName: node - linkType: hard - -"eslint-plugin-storybook@npm:^10.3.6": - version: 10.3.6 - resolution: "eslint-plugin-storybook@npm:10.3.6" - dependencies: - "@typescript-eslint/utils": "npm:^8.48.0" - peerDependencies: - eslint: ">=8" - storybook: ^10.3.6 - checksum: 10c0/2b9d8950a446b8177485f9fccaf8476aa3e83cb7e9ea5b5ba53785f679d6ba08c44bd0b1a172bc59254f3635d93cd5a78dc8b954b285ed55da51b5750f308695 - languageName: node - linkType: hard - -"eslint-scope@npm:^9.1.2": - version: 9.1.2 - resolution: "eslint-scope@npm:9.1.2" - dependencies: - "@types/esrecurse": "npm:^4.3.1" - "@types/estree": "npm:^1.0.8" - esrecurse: "npm:^4.3.0" - estraverse: "npm:^5.2.0" - checksum: 10c0/9fb8bca5a73e5741efb6cec84467027b6cb6f4203ff9b43a938e272c5cd30800bde46a5c20dfd1609f840225f0b62b7673be391b20acadf8658ca9fa4729b3dd - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^3.4.3": - version: 3.4.3 - resolution: "eslint-visitor-keys@npm:3.4.3" - checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^4.2.1": - version: 4.2.1 - resolution: "eslint-visitor-keys@npm:4.2.1" - checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^5.0.0, eslint-visitor-keys@npm:^5.0.1": - version: 5.0.1 - resolution: "eslint-visitor-keys@npm:5.0.1" - checksum: 10c0/16190bdf2cbae40a1109384c94450c526a79b0b9c3cb21e544256ed85ac48a4b84db66b74a6561d20fe6ab77447f150d711c2ad5ad74df4fcc133736bce99678 - languageName: node - linkType: hard - -"eslint@npm:^10.3.0": - version: 10.3.0 - resolution: "eslint@npm:10.3.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.8.0" - "@eslint-community/regexpp": "npm:^4.12.2" - "@eslint/config-array": "npm:^0.23.5" - "@eslint/config-helpers": "npm:^0.5.5" - "@eslint/core": "npm:^1.2.1" - "@eslint/plugin-kit": "npm:^0.7.1" - "@humanfs/node": "npm:^0.16.6" - "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.4.2" - "@types/estree": "npm:^1.0.6" - ajv: "npm:^6.14.0" - cross-spawn: "npm:^7.0.6" - debug: "npm:^4.3.2" - escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^9.1.2" - eslint-visitor-keys: "npm:^5.0.1" - espree: "npm:^11.2.0" - esquery: "npm:^1.7.0" - esutils: "npm:^2.0.2" - fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^8.0.0" - find-up: "npm:^5.0.0" - glob-parent: "npm:^6.0.2" - ignore: "npm:^5.2.0" - imurmurhash: "npm:^0.1.4" - is-glob: "npm:^4.0.0" - json-stable-stringify-without-jsonify: "npm:^1.0.1" - minimatch: "npm:^10.2.4" - natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.3" - peerDependencies: - jiti: "*" - peerDependenciesMeta: - jiti: - optional: true - bin: - eslint: bin/eslint.js - checksum: 10c0/81e3ceba949f62d1b530660279db86cf814f5dc43d7cc3759a8008fe4fc679d46568279fe1cceb7ddbbc98ab57a96ae524f6e811ffc6897b49b90ea08aa785e5 - languageName: node - linkType: hard - -"espree@npm:^11.2.0": - version: 11.2.0 - resolution: "espree@npm:11.2.0" - dependencies: - acorn: "npm:^8.16.0" - acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^5.0.1" - checksum: 10c0/cf87e18ffd9dc113eb8d16588e7757701bc10c9934a71cce8b89c2611d51672681a918307bd6b19ac3ccd0e7ba1cbccc2f815b36b52fa7e73097b251014c3d81 - languageName: node - linkType: hard - -"esprima@npm:~4.0.0": - version: 4.0.1 - resolution: "esprima@npm:4.0.1" - bin: - esparse: ./bin/esparse.js - esvalidate: ./bin/esvalidate.js - checksum: 10c0/ad4bab9ead0808cf56501750fd9d3fb276f6b105f987707d059005d57e182d18a7c9ec7f3a01794ebddcca676773e42ca48a32d67a250c9d35e009ca613caba3 - languageName: node - linkType: hard - -"esquery@npm:^1.7.0": - version: 1.7.0 - resolution: "esquery@npm:1.7.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 - languageName: node - linkType: hard - -"esrecurse@npm:^4.3.0": - version: 4.3.0 - resolution: "esrecurse@npm:4.3.0" - dependencies: - estraverse: "npm:^5.2.0" - checksum: 10c0/81a37116d1408ded88ada45b9fb16dbd26fba3aadc369ce50fcaf82a0bac12772ebd7b24cd7b91fc66786bf2c1ac7b5f196bc990a473efff972f5cb338877cf5 - languageName: node - linkType: hard - -"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": - version: 5.3.0 - resolution: "estraverse@npm:5.3.0" - checksum: 10c0/1ff9447b96263dec95d6d67431c5e0771eb9776427421260a3e2f0fdd5d6bd4f8e37a7338f5ad2880c9f143450c9b1e4fc2069060724570a49cf9cf0312bd107 - languageName: node - linkType: hard - -"estree-walker@npm:^2.0.2": - version: 2.0.2 - resolution: "estree-walker@npm:2.0.2" - checksum: 10c0/53a6c54e2019b8c914dc395890153ffdc2322781acf4bd7d1a32d7aedc1710807bdcd866ac133903d5629ec601fbb50abe8c2e5553c7f5a0afdd9b6af6c945af - languageName: node - linkType: hard - -"esutils@npm:^2.0.2": - version: 2.0.3 - resolution: "esutils@npm:2.0.3" - checksum: 10c0/9a2fe69a41bfdade834ba7c42de4723c97ec776e40656919c62cbd13607c45e127a003f05f724a1ea55e5029a4cf2de444b13009f2af71271e42d93a637137c7 - languageName: node - linkType: hard - -"exponential-backoff@npm:^3.1.1": - version: 3.1.1 - resolution: "exponential-backoff@npm:3.1.1" - checksum: 10c0/160456d2d647e6019640bd07111634d8c353038d9fa40176afb7cd49b0548bdae83b56d05e907c2cce2300b81cae35d800ef92fefb9d0208e190fa3b7d6bb579 - languageName: node - linkType: hard - -"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": - version: 3.1.3 - resolution: "fast-deep-equal@npm:3.1.3" - checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 - languageName: node - linkType: hard - -"fast-json-stable-stringify@npm:^2.0.0": - version: 2.1.0 - resolution: "fast-json-stable-stringify@npm:2.1.0" - checksum: 10c0/7f081eb0b8a64e0057b3bb03f974b3ef00135fbf36c1c710895cd9300f13c94ba809bb3a81cf4e1b03f6e5285610a61abbd7602d0652de423144dfee5a389c9b - languageName: node - linkType: hard - -"fast-levenshtein@npm:^2.0.6": - version: 2.0.6 - resolution: "fast-levenshtein@npm:2.0.6" - checksum: 10c0/111972b37338bcb88f7d9e2c5907862c280ebf4234433b95bc611e518d192ccb2d38119c4ac86e26b668d75f7f3894f4ff5c4982899afced7ca78633b08287c4 - languageName: node - linkType: hard - -"fdir@npm:^6.5.0": - version: 6.5.0 - resolution: "fdir@npm:6.5.0" - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f - languageName: node - linkType: hard - -"file-entry-cache@npm:^8.0.0": - version: 8.0.0 - resolution: "file-entry-cache@npm:8.0.0" - dependencies: - flat-cache: "npm:^4.0.0" - checksum: 10c0/9e2b5938b1cd9b6d7e3612bdc533afd4ac17b2fc646569e9a8abbf2eb48e5eb8e316bc38815a3ef6a1b456f4107f0d0f055a614ca613e75db6bf9ff4d72c1638 - languageName: node - linkType: hard - -"find-up@npm:^5.0.0": - version: 5.0.0 - resolution: "find-up@npm:5.0.0" - dependencies: - locate-path: "npm:^6.0.0" - path-exists: "npm:^4.0.0" - checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a - languageName: node - linkType: hard - -"flat-cache@npm:^4.0.0": - version: 4.0.1 - resolution: "flat-cache@npm:4.0.1" - dependencies: - flatted: "npm:^3.2.9" - keyv: "npm:^4.5.4" - checksum: 10c0/2c59d93e9faa2523e4fda6b4ada749bed432cfa28c8e251f33b25795e426a1c6dbada777afb1f74fcfff33934fdbdea921ee738fcc33e71adc9d6eca984a1cfc - languageName: node - linkType: hard - -"flatted@npm:^3.2.9": - version: 3.4.2 - resolution: "flatted@npm:3.4.2" - checksum: 10c0/a65b67aae7172d6cdf63691be7de6c5cd5adbdfdfe2e9da1a09b617c9512ed794037741ee53d93114276bff3f93cd3b0d97d54f9b316e1e4885dde6e9ffdf7ed - languageName: node - linkType: hard - -"foreground-child@npm:^3.1.0": - version: 3.1.1 - resolution: "foreground-child@npm:3.1.1" - dependencies: - cross-spawn: "npm:^7.0.0" - signal-exit: "npm:^4.0.1" - checksum: 10c0/9700a0285628abaeb37007c9a4d92bd49f67210f09067638774338e146c8e9c825c5c877f072b2f75f41dc6a2d0be8664f79ffc03f6576649f54a84fb9b47de0 - languageName: node - linkType: hard - -"fs-minipass@npm:^2.0.0": - version: 2.1.0 - resolution: "fs-minipass@npm:2.1.0" - dependencies: - minipass: "npm:^3.0.0" - checksum: 10c0/703d16522b8282d7299337539c3ed6edddd1afe82435e4f5b76e34a79cd74e488a8a0e26a636afc2440e1a23b03878e2122e3a2cfe375a5cf63c37d92b86a004 - languageName: node - linkType: hard - -"fs-minipass@npm:^3.0.0": - version: 3.0.3 - resolution: "fs-minipass@npm:3.0.3" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94 - languageName: node - linkType: hard - -"fsevents@npm:~2.3.3": - version: 2.3.3 - resolution: "fsevents@npm:2.3.3" - dependencies: - node-gyp: "npm:latest" - checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 - conditions: os=darwin - languageName: node - linkType: hard - -"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": - version: 2.3.3 - resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" - dependencies: - node-gyp: "npm:latest" - conditions: os=darwin - languageName: node - linkType: hard - -"function-bind@npm:^1.1.2": - version: 1.1.2 - resolution: "function-bind@npm:1.1.2" - checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 - languageName: node - linkType: hard - -"gensync@npm:^1.0.0-beta.2": - version: 1.0.0-beta.2 - resolution: "gensync@npm:1.0.0-beta.2" - checksum: 10c0/782aba6cba65b1bb5af3b095d96249d20edbe8df32dbf4696fd49be2583faf676173bf4809386588828e4dd76a3354fcbeb577bab1c833ccd9fc4577f26103f8 - languageName: node - linkType: hard - -"glob-parent@npm:^6.0.2": - version: 6.0.2 - resolution: "glob-parent@npm:6.0.2" - dependencies: - is-glob: "npm:^4.0.3" - checksum: 10c0/317034d88654730230b3f43bb7ad4f7c90257a426e872ea0bf157473ac61c99bf5d205fad8f0185f989be8d2fa6d3c7dce1645d99d545b6ea9089c39f838e7f8 - languageName: node - linkType: hard - -"glob@npm:^10.2.2, glob@npm:^10.3.10": - version: 10.5.0 - resolution: "glob@npm:10.5.0" - dependencies: - foreground-child: "npm:^3.1.0" - jackspeak: "npm:^3.1.2" - minimatch: "npm:^9.0.4" - minipass: "npm:^7.1.2" - package-json-from-dist: "npm:^1.0.0" - path-scurry: "npm:^1.11.1" - bin: - glob: dist/esm/bin.mjs - checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 - languageName: node - linkType: hard - -"glob@npm:^13.0.1": - version: 13.0.6 - resolution: "glob@npm:13.0.6" - dependencies: - minimatch: "npm:^10.2.2" - minipass: "npm:^7.1.3" - path-scurry: "npm:^2.0.2" - checksum: 10c0/269c236f11a9b50357fe7a8c6aadac667e01deb5242b19c84975628f05f4438d8ee1354bb62c5d6c10f37fd59911b54d7799730633a2786660d8c69f1d18120a - languageName: node - linkType: hard - -"globals@npm:^11.1.0": - version: 11.12.0 - resolution: "globals@npm:11.12.0" - checksum: 10c0/758f9f258e7b19226bd8d4af5d3b0dcf7038780fb23d82e6f98932c44e239f884847f1766e8fa9cc5635ccb3204f7fa7314d4408dd4002a5e8ea827b4018f0a1 - languageName: node - linkType: hard - -"graceful-fs@npm:^4.2.6": - version: 4.2.11 - resolution: "graceful-fs@npm:4.2.11" - checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 - languageName: node - linkType: hard - -"has-flag@npm:^3.0.0": - version: 3.0.0 - resolution: "has-flag@npm:3.0.0" - checksum: 10c0/1c6c83b14b8b1b3c25b0727b8ba3e3b647f99e9e6e13eb7322107261de07a4c1be56fc0d45678fc376e09772a3a1642ccdaf8fc69bdf123b6c086598397ce473 - languageName: node - linkType: hard - -"hasown@npm:^2.0.0": - version: 2.0.2 - resolution: "hasown@npm:2.0.2" - dependencies: - function-bind: "npm:^1.1.2" - checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9 - languageName: node - linkType: hard - -"hermes-estree@npm:0.25.1": - version: 0.25.1 - resolution: "hermes-estree@npm:0.25.1" - checksum: 10c0/48be3b2fa37a0cbc77a112a89096fa212f25d06de92781b163d67853d210a8a5c3784fac23d7d48335058f7ed283115c87b4332c2a2abaaccc76d0ead1a282ac - languageName: node - linkType: hard - -"hermes-parser@npm:^0.25.1": - version: 0.25.1 - resolution: "hermes-parser@npm:0.25.1" - dependencies: - hermes-estree: "npm:0.25.1" - checksum: 10c0/3abaa4c6f1bcc25273f267297a89a4904963ea29af19b8e4f6eabe04f1c2c7e9abd7bfc4730ddb1d58f2ea04b6fee74053d8bddb5656ec6ebf6c79cc8d14202c - languageName: node - linkType: hard - -"http-cache-semantics@npm:^4.1.1": - version: 4.1.1 - resolution: "http-cache-semantics@npm:4.1.1" - checksum: 10c0/ce1319b8a382eb3cbb4a37c19f6bfe14e5bb5be3d09079e885e8c513ab2d3cd9214902f8a31c9dc4e37022633ceabfc2d697405deeaf1b8f3552bb4ed996fdfc - languageName: node - linkType: hard - -"http-proxy-agent@npm:^7.0.0": - version: 7.0.2 - resolution: "http-proxy-agent@npm:7.0.2" - dependencies: - agent-base: "npm:^7.1.0" - debug: "npm:^4.3.4" - checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 - languageName: node - linkType: hard - -"https-proxy-agent@npm:^7.0.1": - version: 7.0.5 - resolution: "https-proxy-agent@npm:7.0.5" - dependencies: - agent-base: "npm:^7.0.2" - debug: "npm:4" - checksum: 10c0/2490e3acec397abeb88807db52cac59102d5ed758feee6df6112ab3ccd8325e8a1ce8bce6f4b66e5470eca102d31e425ace904242e4fa28dbe0c59c4bafa7b2c - languageName: node - linkType: hard - -"iconv-lite@npm:^0.6.2": - version: 0.6.3 - resolution: "iconv-lite@npm:0.6.3" - dependencies: - safer-buffer: "npm:>= 2.1.2 < 3.0.0" - checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 - languageName: node - linkType: hard - -"ignore@npm:^5.2.0": - version: 5.3.1 - resolution: "ignore@npm:5.3.1" - checksum: 10c0/703f7f45ffb2a27fb2c5a8db0c32e7dee66b33a225d28e8db4e1be6474795f606686a6e3bcc50e1aa12f2042db4c9d4a7d60af3250511de74620fbed052ea4cd - languageName: node - linkType: hard - -"ignore@npm:^7.0.5": - version: 7.0.5 - resolution: "ignore@npm:7.0.5" - checksum: 10c0/ae00db89fe873064a093b8999fe4cc284b13ef2a178636211842cceb650b9c3e390d3339191acb145d81ed5379d2074840cf0c33a20bdbd6f32821f79eb4ad5d - languageName: node - linkType: hard - -"imurmurhash@npm:^0.1.4": - version: 0.1.4 - resolution: "imurmurhash@npm:0.1.4" - checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 - languageName: node - linkType: hard - -"indent-string@npm:^4.0.0": - version: 4.0.0 - resolution: "indent-string@npm:4.0.0" - checksum: 10c0/1e1904ddb0cb3d6cce7cd09e27a90184908b7a5d5c21b92e232c93579d314f0b83c246ffb035493d0504b1e9147ba2c9b21df0030f48673fba0496ecd698161f - languageName: node - linkType: hard - -"ip-address@npm:^9.0.5": - version: 9.0.5 - resolution: "ip-address@npm:9.0.5" - dependencies: - jsbn: "npm:1.1.0" - sprintf-js: "npm:^1.1.3" - checksum: 10c0/331cd07fafcb3b24100613e4b53e1a2b4feab11e671e655d46dc09ee233da5011284d09ca40c4ecbdfe1d0004f462958675c224a804259f2f78d2465a87824bc - languageName: node - linkType: hard - -"is-core-module@npm:^2.13.0": - version: 2.13.1 - resolution: "is-core-module@npm:2.13.1" - dependencies: - hasown: "npm:^2.0.0" - checksum: 10c0/2cba9903aaa52718f11c4896dabc189bab980870aae86a62dc0d5cedb546896770ee946fb14c84b7adf0735f5eaea4277243f1b95f5cefa90054f92fbcac2518 - languageName: node - linkType: hard - -"is-docker@npm:^3.0.0": - version: 3.0.0 - resolution: "is-docker@npm:3.0.0" - bin: - is-docker: cli.js - checksum: 10c0/d2c4f8e6d3e34df75a5defd44991b6068afad4835bb783b902fa12d13ebdb8f41b2a199dcb0b5ed2cb78bfee9e4c0bbdb69c2d9646f4106464674d3e697a5856 - languageName: node - linkType: hard - -"is-extglob@npm:^2.1.1": - version: 2.1.1 - resolution: "is-extglob@npm:2.1.1" - checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 - languageName: node - linkType: hard - -"is-fullwidth-code-point@npm:^3.0.0": - version: 3.0.0 - resolution: "is-fullwidth-code-point@npm:3.0.0" - checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc - languageName: node - linkType: hard - -"is-glob@npm:^4.0.0, is-glob@npm:^4.0.3": - version: 4.0.3 - resolution: "is-glob@npm:4.0.3" - dependencies: - is-extglob: "npm:^2.1.1" - checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a - languageName: node - linkType: hard - -"is-inside-container@npm:^1.0.0": - version: 1.0.0 - resolution: "is-inside-container@npm:1.0.0" - dependencies: - is-docker: "npm:^3.0.0" - bin: - is-inside-container: cli.js - checksum: 10c0/a8efb0e84f6197e6ff5c64c52890fa9acb49b7b74fed4da7c95383965da6f0fa592b4dbd5e38a79f87fc108196937acdbcd758fcefc9b140e479b39ce1fcd1cd - languageName: node - linkType: hard - -"is-lambda@npm:^1.0.1": - version: 1.0.1 - resolution: "is-lambda@npm:1.0.1" - checksum: 10c0/85fee098ae62ba6f1e24cf22678805473c7afd0fb3978a3aa260e354cb7bcb3a5806cf0a98403188465efedec41ab4348e8e4e79305d409601323855b3839d4d - languageName: node - linkType: hard - -"is-wsl@npm:^3.1.0": - version: 3.1.0 - resolution: "is-wsl@npm:3.1.0" - dependencies: - is-inside-container: "npm:^1.0.0" - checksum: 10c0/d3317c11995690a32c362100225e22ba793678fe8732660c6de511ae71a0ff05b06980cf21f98a6bf40d7be0e9e9506f859abe00a1118287d63e53d0a3d06947 - languageName: node - linkType: hard - -"isexe@npm:^2.0.0": - version: 2.0.0 - resolution: "isexe@npm:2.0.0" - checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d - languageName: node - linkType: hard - -"isexe@npm:^3.1.1": - version: 3.1.1 - resolution: "isexe@npm:3.1.1" - checksum: 10c0/9ec257654093443eb0a528a9c8cbba9c0ca7616ccb40abd6dde7202734d96bb86e4ac0d764f0f8cd965856aacbff2f4ce23e730dc19dfb41e3b0d865ca6fdcc7 - languageName: node - linkType: hard - -"jackspeak@npm:^3.1.2": - version: 3.4.0 - resolution: "jackspeak@npm:3.4.0" - dependencies: - "@isaacs/cliui": "npm:^8.0.2" - "@pkgjs/parseargs": "npm:^0.11.0" - dependenciesMeta: - "@pkgjs/parseargs": - optional: true - checksum: 10c0/7e42d1ea411b4d57d43ea8a6afbca9224382804359cb72626d0fc45bb8db1de5ad0248283c3db45fe73e77210750d4fcc7c2b4fe5d24fda94aaa24d658295c5f - languageName: node - linkType: hard - -"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": - version: 4.0.0 - resolution: "js-tokens@npm:4.0.0" - checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed - languageName: node - linkType: hard - -"jsbn@npm:1.1.0": - version: 1.1.0 - resolution: "jsbn@npm:1.1.0" - checksum: 10c0/4f907fb78d7b712e11dea8c165fe0921f81a657d3443dde75359ed52eb2b5d33ce6773d97985a089f09a65edd80b11cb75c767b57ba47391fee4c969f7215c96 - languageName: node - linkType: hard - -"jsesc@npm:^2.5.1": - version: 2.5.2 - resolution: "jsesc@npm:2.5.2" - bin: - jsesc: bin/jsesc - checksum: 10c0/dbf59312e0ebf2b4405ef413ec2b25abb5f8f4d9bc5fb8d9f90381622ebca5f2af6a6aa9a8578f65903f9e33990a6dc798edd0ce5586894bf0e9e31803a1de88 - languageName: node - linkType: hard - -"jsesc@npm:^3.0.2": - version: 3.0.2 - resolution: "jsesc@npm:3.0.2" - bin: - jsesc: bin/jsesc - checksum: 10c0/ef22148f9e793180b14d8a145ee6f9f60f301abf443288117b4b6c53d0ecd58354898dc506ccbb553a5f7827965cd38bc5fb726575aae93c5e8915e2de8290e1 - languageName: node - linkType: hard - -"json-buffer@npm:3.0.1": - version: 3.0.1 - resolution: "json-buffer@npm:3.0.1" - checksum: 10c0/0d1c91569d9588e7eef2b49b59851f297f3ab93c7b35c7c221e288099322be6b562767d11e4821da500f3219542b9afd2e54c5dc573107c1126ed1080f8e96d7 - languageName: node - linkType: hard - -"json-schema-traverse@npm:^0.4.1": - version: 0.4.1 - resolution: "json-schema-traverse@npm:0.4.1" - checksum: 10c0/108fa90d4cc6f08243aedc6da16c408daf81793bf903e9fd5ab21983cda433d5d2da49e40711da016289465ec2e62e0324dcdfbc06275a607fe3233fde4942ce - languageName: node - linkType: hard - -"json-stable-stringify-without-jsonify@npm:^1.0.1": - version: 1.0.1 - resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" - checksum: 10c0/cb168b61fd4de83e58d09aaa6425ef71001bae30d260e2c57e7d09a5fd82223e2f22a042dedaab8db23b7d9ae46854b08bb1f91675a8be11c5cffebef5fb66a5 - languageName: node - linkType: hard - -"json5@npm:^2.2.2, json5@npm:^2.2.3": - version: 2.2.3 - resolution: "json5@npm:2.2.3" - bin: - json5: lib/cli.js - checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c - languageName: node - linkType: hard - -"keycloakify@npm:^11.15.3": - version: 11.15.3 - resolution: "keycloakify@npm:11.15.3" - dependencies: - tsafe: "npm:^1.8.5" - bin: - keycloakify: bin/main.js - checksum: 10c0/b81fd745f7e0bf68b883dd2766b0b84de68f0297424eabce997015c8fa7605f362ea5d935fc83889c0ca81afcb7eb49a998c9bf02a0160c7f7fc014c7f468afa - languageName: node - linkType: hard - -"keyv@npm:^4.5.4": - version: 4.5.4 - resolution: "keyv@npm:4.5.4" - dependencies: - json-buffer: "npm:3.0.1" - checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e - languageName: node - linkType: hard - -"levn@npm:^0.4.1": - version: 0.4.1 - resolution: "levn@npm:0.4.1" - dependencies: - prelude-ls: "npm:^1.2.1" - type-check: "npm:~0.4.0" - checksum: 10c0/effb03cad7c89dfa5bd4f6989364bfc79994c2042ec5966cb9b95990e2edee5cd8969ddf42616a0373ac49fac1403437deaf6e9050fbbaa3546093a59b9ac94e - languageName: node - linkType: hard - -"lightningcss-android-arm64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-android-arm64@npm:1.32.0" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-darwin-arm64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-darwin-arm64@npm:1.32.0" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-darwin-x64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-darwin-x64@npm:1.32.0" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"lightningcss-freebsd-x64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-freebsd-x64@npm:1.32.0" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"lightningcss-linux-arm-gnueabihf@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"lightningcss-linux-arm64-gnu@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"lightningcss-linux-arm64-musl@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"lightningcss-linux-x64-gnu@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"lightningcss-linux-x64-musl@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-x64-musl@npm:1.32.0" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"lightningcss-win32-arm64-msvc@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-win32-x64-msvc@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"lightningcss@npm:^1.32.0": - version: 1.32.0 - resolution: "lightningcss@npm:1.32.0" - dependencies: - detect-libc: "npm:^2.0.3" - lightningcss-android-arm64: "npm:1.32.0" - lightningcss-darwin-arm64: "npm:1.32.0" - lightningcss-darwin-x64: "npm:1.32.0" - lightningcss-freebsd-x64: "npm:1.32.0" - lightningcss-linux-arm-gnueabihf: "npm:1.32.0" - lightningcss-linux-arm64-gnu: "npm:1.32.0" - lightningcss-linux-arm64-musl: "npm:1.32.0" - lightningcss-linux-x64-gnu: "npm:1.32.0" - lightningcss-linux-x64-musl: "npm:1.32.0" - lightningcss-win32-arm64-msvc: "npm:1.32.0" - lightningcss-win32-x64-msvc: "npm:1.32.0" - dependenciesMeta: - lightningcss-android-arm64: - optional: true - lightningcss-darwin-arm64: - optional: true - lightningcss-darwin-x64: - optional: true - lightningcss-freebsd-x64: - optional: true - lightningcss-linux-arm-gnueabihf: - optional: true - lightningcss-linux-arm64-gnu: - optional: true - lightningcss-linux-arm64-musl: - optional: true - lightningcss-linux-x64-gnu: - optional: true - lightningcss-linux-x64-musl: - optional: true - lightningcss-win32-arm64-msvc: - optional: true - lightningcss-win32-x64-msvc: - optional: true - checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 - languageName: node - linkType: hard - -"locate-path@npm:^6.0.0": - version: 6.0.0 - resolution: "locate-path@npm:6.0.0" - dependencies: - p-locate: "npm:^5.0.0" - checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3 - languageName: node - linkType: hard - -"loculus-keycloak-theme@workspace:.": - version: 0.0.0-use.local - resolution: "loculus-keycloak-theme@workspace:." - dependencies: - "@storybook/react": "npm:^8.4.6" - "@storybook/react-vite": "npm:^10.3.6" - "@types/react": "npm:^18.3.13" - "@types/react-dom": "npm:^18.3.1" - "@typescript-eslint/eslint-plugin": "npm:^8.59.1" - "@typescript-eslint/parser": "npm:^8.59.1" - "@vitejs/plugin-react": "npm:^5.1.4" - eslint: "npm:^10.3.0" - eslint-plugin-react-hooks: "npm:^7.1.1" - eslint-plugin-react-refresh: "npm:^0.5.2" - eslint-plugin-storybook: "npm:^10.3.6" - keycloakify: "npm:^11.15.3" - prettier: "npm:3.8.3" - react: "npm:^18.2.0" - react-dom: "npm:^18.2.0" - storybook: "npm:^10.3.6" - typescript: "npm:^5.2.2" - vite: "npm:^8.0.10" - languageName: unknown - linkType: soft - -"loose-envify@npm:^1.1.0": - version: 1.4.0 - resolution: "loose-envify@npm:1.4.0" - dependencies: - js-tokens: "npm:^3.0.0 || ^4.0.0" - bin: - loose-envify: cli.js - checksum: 10c0/655d110220983c1a4b9c0c679a2e8016d4b67f6e9c7b5435ff5979ecdb20d0813f4dec0a08674fcbdd4846a3f07edbb50a36811fd37930b94aaa0d9daceb017e - languageName: node - linkType: hard - -"loupe@npm:^3.1.0": - version: 3.1.3 - resolution: "loupe@npm:3.1.3" - checksum: 10c0/f5dab4144254677de83a35285be1b8aba58b3861439ce4ba65875d0d5f3445a4a496daef63100ccf02b2dbc25bf58c6db84c9cb0b96d6435331e9d0a33b48541 - languageName: node - linkType: hard - -"loupe@npm:^3.1.4": - version: 3.1.4 - resolution: "loupe@npm:3.1.4" - checksum: 10c0/5c2e6aefaad25f812d361c750b8cf4ff91d68de289f141d7c85c2ce9bb79eeefa06a93c85f7b87cba940531ed8f15e492f32681d47eed23842ad1963eb3a154d - languageName: node - linkType: hard - -"lru-cache@npm:^10.0.1": - version: 10.4.3 - resolution: "lru-cache@npm:10.4.3" - checksum: 10c0/ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb - languageName: node - linkType: hard - -"lru-cache@npm:^10.2.0": - version: 10.2.2 - resolution: "lru-cache@npm:10.2.2" - checksum: 10c0/402d31094335851220d0b00985084288136136992979d0e015f0f1697e15d1c86052d7d53ae86b614e5b058425606efffc6969a31a091085d7a2b80a8a1e26d6 - languageName: node - linkType: hard - -"lru-cache@npm:^11.0.0": - version: 11.2.4 - resolution: "lru-cache@npm:11.2.4" - checksum: 10c0/4a24f9b17537619f9144d7b8e42cd5a225efdfd7076ebe7b5e7dc02b860a818455201e67fbf000765233fe7e339d3c8229fc815e9b58ee6ede511e07608c19b2 - languageName: node - linkType: hard - -"lru-cache@npm:^5.1.1": - version: 5.1.1 - resolution: "lru-cache@npm:5.1.1" - dependencies: - yallist: "npm:^3.0.2" - checksum: 10c0/89b2ef2ef45f543011e38737b8a8622a2f8998cddf0e5437174ef8f1f70a8b9d14a918ab3e232cb3ba343b7abddffa667f0b59075b2b80e6b4d63c3de6127482 - languageName: node - linkType: hard - -"magic-string@npm:^0.30.0": - version: 0.30.10 - resolution: "magic-string@npm:0.30.10" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.4.15" - checksum: 10c0/aa9ca17eae571a19bce92c8221193b6f93ee8511abb10f085e55ffd398db8e4c089a208d9eac559deee96a08b7b24d636ea4ab92f09c6cf42a7d1af51f7fd62b - languageName: node - linkType: hard - -"make-fetch-happen@npm:^13.0.0": - version: 13.0.1 - resolution: "make-fetch-happen@npm:13.0.1" - dependencies: - "@npmcli/agent": "npm:^2.0.0" - cacache: "npm:^18.0.0" - http-cache-semantics: "npm:^4.1.1" - is-lambda: "npm:^1.0.1" - minipass: "npm:^7.0.2" - minipass-fetch: "npm:^3.0.0" - minipass-flush: "npm:^1.0.5" - minipass-pipeline: "npm:^1.2.4" - negotiator: "npm:^0.6.3" - proc-log: "npm:^4.2.0" - promise-retry: "npm:^2.0.1" - ssri: "npm:^10.0.0" - checksum: 10c0/df5f4dbb6d98153b751bccf4dc4cc500de85a96a9331db9805596c46aa9f99d9555983954e6c1266d9f981ae37a9e4647f42b9a4bb5466f867f4012e582c9e7e - languageName: node - linkType: hard - -"min-indent@npm:^1.0.0, min-indent@npm:^1.0.1": - version: 1.0.1 - resolution: "min-indent@npm:1.0.1" - checksum: 10c0/7e207bd5c20401b292de291f02913230cb1163abca162044f7db1d951fa245b174dc00869d40dd9a9f32a885ad6a5f3e767ee104cf278f399cb4e92d3f582d5c - languageName: node - linkType: hard - -"minimatch@npm:^10.2.2": - version: 10.2.4 - resolution: "minimatch@npm:10.2.4" - dependencies: - brace-expansion: "npm:^5.0.2" - checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945 - languageName: node - linkType: hard - -"minimatch@npm:^10.2.4": - version: 10.2.5 - resolution: "minimatch@npm:10.2.5" - dependencies: - brace-expansion: "npm:^5.0.5" - checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd - languageName: node - linkType: hard - -"minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": - version: 9.0.9 - resolution: "minimatch@npm:9.0.9" - dependencies: - brace-expansion: "npm:^2.0.2" - checksum: 10c0/0b6a58530dbb00361745aa6c8cffaba4c90f551afe7c734830bd95fd88ebf469dd7355a027824ea1d09e37181cfeb0a797fb17df60c15ac174303ac110eb7e86 - languageName: node - linkType: hard - -"minimist@npm:^1.2.6": - version: 1.2.8 - resolution: "minimist@npm:1.2.8" - checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 - languageName: node - linkType: hard - -"minipass-collect@npm:^2.0.1": - version: 2.0.1 - resolution: "minipass-collect@npm:2.0.1" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e - languageName: node - linkType: hard - -"minipass-fetch@npm:^3.0.0": - version: 3.0.5 - resolution: "minipass-fetch@npm:3.0.5" - dependencies: - encoding: "npm:^0.1.13" - minipass: "npm:^7.0.3" - minipass-sized: "npm:^1.0.3" - minizlib: "npm:^2.1.2" - dependenciesMeta: - encoding: - optional: true - checksum: 10c0/9d702d57f556274286fdd97e406fc38a2f5c8d15e158b498d7393b1105974b21249289ec571fa2b51e038a4872bfc82710111cf75fae98c662f3d6f95e72152b - languageName: node - linkType: hard - -"minipass-flush@npm:^1.0.5": - version: 1.0.5 - resolution: "minipass-flush@npm:1.0.5" - dependencies: - minipass: "npm:^3.0.0" - checksum: 10c0/2a51b63feb799d2bb34669205eee7c0eaf9dce01883261a5b77410c9408aa447e478efd191b4de6fc1101e796ff5892f8443ef20d9544385819093dbb32d36bd - languageName: node - linkType: hard - -"minipass-pipeline@npm:^1.2.4": - version: 1.2.4 - resolution: "minipass-pipeline@npm:1.2.4" - dependencies: - minipass: "npm:^3.0.0" - checksum: 10c0/cbda57cea20b140b797505dc2cac71581a70b3247b84480c1fed5ca5ba46c25ecc25f68bfc9e6dcb1a6e9017dab5c7ada5eab73ad4f0a49d84e35093e0c643f2 - languageName: node - linkType: hard - -"minipass-sized@npm:^1.0.3": - version: 1.0.3 - resolution: "minipass-sized@npm:1.0.3" - dependencies: - minipass: "npm:^3.0.0" - checksum: 10c0/298f124753efdc745cfe0f2bdfdd81ba25b9f4e753ca4a2066eb17c821f25d48acea607dfc997633ee5bf7b6dfffb4eee4f2051eb168663f0b99fad2fa4829cb - languageName: node - linkType: hard - -"minipass@npm:^3.0.0": - version: 3.3.6 - resolution: "minipass@npm:3.3.6" - dependencies: - yallist: "npm:^4.0.0" - checksum: 10c0/a114746943afa1dbbca8249e706d1d38b85ed1298b530f5808ce51f8e9e941962e2a5ad2e00eae7dd21d8a4aae6586a66d4216d1a259385e9d0358f0c1eba16c - languageName: node - linkType: hard - -"minipass@npm:^5.0.0": - version: 5.0.0 - resolution: "minipass@npm:5.0.0" - checksum: 10c0/a91d8043f691796a8ac88df039da19933ef0f633e3d7f0d35dcd5373af49131cf2399bfc355f41515dc495e3990369c3858cd319e5c2722b4753c90bf3152462 - languageName: node - linkType: hard - -"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.1.2": - version: 7.1.2 - resolution: "minipass@npm:7.1.2" - checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 - languageName: node - linkType: hard - -"minipass@npm:^7.1.3": - version: 7.1.3 - resolution: "minipass@npm:7.1.3" - checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb - languageName: node - linkType: hard - -"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": - version: 2.1.2 - resolution: "minizlib@npm:2.1.2" - dependencies: - minipass: "npm:^3.0.0" - yallist: "npm:^4.0.0" - checksum: 10c0/64fae024e1a7d0346a1102bb670085b17b7f95bf6cfdf5b128772ec8faf9ea211464ea4add406a3a6384a7d87a0cd1a96263692134323477b4fb43659a6cab78 - languageName: node - linkType: hard - -"mkdirp@npm:^1.0.3": - version: 1.0.4 - resolution: "mkdirp@npm:1.0.4" - bin: - mkdirp: bin/cmd.js - checksum: 10c0/46ea0f3ffa8bc6a5bc0c7081ffc3907777f0ed6516888d40a518c5111f8366d97d2678911ad1a6882bf592fa9de6c784fea32e1687bb94e1f4944170af48a5cf - languageName: node - linkType: hard - -"ms@npm:2.1.2": - version: 2.1.2 - resolution: "ms@npm:2.1.2" - checksum: 10c0/a437714e2f90dbf881b5191d35a6db792efbca5badf112f87b9e1c712aace4b4b9b742dd6537f3edf90fd6f684de897cec230abde57e87883766712ddda297cc - languageName: node - linkType: hard - -"ms@npm:^2.1.3": - version: 2.1.3 - resolution: "ms@npm:2.1.3" - checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 - languageName: node - linkType: hard - -"nanoid@npm:^3.3.11": - version: 3.3.11 - resolution: "nanoid@npm:3.3.11" - bin: - nanoid: bin/nanoid.cjs - checksum: 10c0/40e7f70b3d15f725ca072dfc4f74e81fcf1fbb02e491cf58ac0c79093adc9b0a73b152bcde57df4b79cd097e13023d7504acb38404a4da7bc1cd8e887b82fe0b - languageName: node - linkType: hard - -"natural-compare@npm:^1.4.0": - version: 1.4.0 - resolution: "natural-compare@npm:1.4.0" - checksum: 10c0/f5f9a7974bfb28a91afafa254b197f0f22c684d4a1731763dda960d2c8e375b36c7d690e0d9dc8fba774c537af14a7e979129bca23d88d052fbeb9466955e447 - languageName: node - linkType: hard - -"negotiator@npm:^0.6.3": - version: 0.6.4 - resolution: "negotiator@npm:0.6.4" - checksum: 10c0/3e677139c7fb7628a6f36335bf11a885a62c21d5390204590a1a214a5631fcbe5ea74ef6a610b60afe84b4d975cbe0566a23f20ee17c77c73e74b80032108dea - languageName: node - linkType: hard - -"node-gyp@npm:latest": - version: 10.2.0 - resolution: "node-gyp@npm:10.2.0" - dependencies: - env-paths: "npm:^2.2.0" - exponential-backoff: "npm:^3.1.1" - glob: "npm:^10.3.10" - graceful-fs: "npm:^4.2.6" - make-fetch-happen: "npm:^13.0.0" - nopt: "npm:^7.0.0" - proc-log: "npm:^4.1.0" - semver: "npm:^7.3.5" - tar: "npm:^6.2.1" - which: "npm:^4.0.0" - bin: - node-gyp: bin/node-gyp.js - checksum: 10c0/00630d67dbd09a45aee0a5d55c05e3916ca9e6d427ee4f7bc392d2d3dc5fad7449b21fc098dd38260a53d9dcc9c879b36704a1994235d4707e7271af7e9a835b - languageName: node - linkType: hard - -"node-releases@npm:^2.0.14": - version: 2.0.14 - resolution: "node-releases@npm:2.0.14" - checksum: 10c0/199fc93773ae70ec9969bc6d5ac5b2bbd6eb986ed1907d751f411fef3ede0e4bfdb45ceb43711f8078bea237b6036db8b1bf208f6ff2b70c7d615afd157f3ab9 - languageName: node - linkType: hard - -"node-releases@npm:^2.0.18": - version: 2.0.18 - resolution: "node-releases@npm:2.0.18" - checksum: 10c0/786ac9db9d7226339e1dc84bbb42007cb054a346bd9257e6aa154d294f01bc6a6cddb1348fa099f079be6580acbb470e3c048effd5f719325abd0179e566fd27 - languageName: node - linkType: hard - -"nopt@npm:^7.0.0": - version: 7.2.1 - resolution: "nopt@npm:7.2.1" - dependencies: - abbrev: "npm:^2.0.0" - bin: - nopt: bin/nopt.js - checksum: 10c0/a069c7c736767121242037a22a788863accfa932ab285a1eb569eb8cd534b09d17206f68c37f096ae785647435e0c5a5a0a67b42ec743e481a455e5ae6a6df81 - languageName: node - linkType: hard - -"open@npm:^10.2.0": - version: 10.2.0 - resolution: "open@npm:10.2.0" - dependencies: - default-browser: "npm:^5.2.1" - define-lazy-prop: "npm:^3.0.0" - is-inside-container: "npm:^1.0.0" - wsl-utils: "npm:^0.1.0" - checksum: 10c0/5a36d0c1fd2f74ce553beb427ca8b8494b623fc22c6132d0c1688f246a375e24584ea0b44c67133d9ab774fa69be8e12fbe1ff12504b1142bd960fb09671948f - languageName: node - linkType: hard - -"optionator@npm:^0.9.3": - version: 0.9.4 - resolution: "optionator@npm:0.9.4" - dependencies: - deep-is: "npm:^0.1.3" - fast-levenshtein: "npm:^2.0.6" - levn: "npm:^0.4.1" - prelude-ls: "npm:^1.2.1" - type-check: "npm:^0.4.0" - word-wrap: "npm:^1.2.5" - checksum: 10c0/4afb687a059ee65b61df74dfe87d8d6815cd6883cb8b3d5883a910df72d0f5d029821f37025e4bccf4048873dbdb09acc6d303d27b8f76b1a80dd5a7d5334675 - languageName: node - linkType: hard - -"p-limit@npm:^3.0.2": - version: 3.1.0 - resolution: "p-limit@npm:3.1.0" - dependencies: - yocto-queue: "npm:^0.1.0" - checksum: 10c0/9db675949dbdc9c3763c89e748d0ef8bdad0afbb24d49ceaf4c46c02c77d30db4e0652ed36d0a0a7a95154335fab810d95c86153105bb73b3a90448e2bb14e1a - languageName: node - linkType: hard - -"p-locate@npm:^5.0.0": - version: 5.0.0 - resolution: "p-locate@npm:5.0.0" - dependencies: - p-limit: "npm:^3.0.2" - checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a - languageName: node - linkType: hard - -"p-map@npm:^4.0.0": - version: 4.0.0 - resolution: "p-map@npm:4.0.0" - dependencies: - aggregate-error: "npm:^3.0.0" - checksum: 10c0/592c05bd6262c466ce269ff172bb8de7c6975afca9b50c975135b974e9bdaafbfe80e61aaaf5be6d1200ba08b30ead04b88cfa7e25ff1e3b93ab28c9f62a2c75 - languageName: node - linkType: hard - -"package-json-from-dist@npm:^1.0.0": - version: 1.0.1 - resolution: "package-json-from-dist@npm:1.0.1" - checksum: 10c0/62ba2785eb655fec084a257af34dbe24292ab74516d6aecef97ef72d4897310bc6898f6c85b5cd22770eaa1ce60d55a0230e150fb6a966e3ecd6c511e23d164b - languageName: node - linkType: hard - -"path-exists@npm:^4.0.0": - version: 4.0.0 - resolution: "path-exists@npm:4.0.0" - checksum: 10c0/8c0bd3f5238188197dc78dced15207a4716c51cc4e3624c44fc97acf69558f5ebb9a2afff486fe1b4ee148e0c133e96c5e11a9aa5c48a3006e3467da070e5e1b - languageName: node - linkType: hard - -"path-key@npm:^3.1.0": - version: 3.1.1 - resolution: "path-key@npm:3.1.1" - checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c - languageName: node - linkType: hard - -"path-parse@npm:^1.0.7": - version: 1.0.7 - resolution: "path-parse@npm:1.0.7" - checksum: 10c0/11ce261f9d294cc7a58d6a574b7f1b935842355ec66fba3c3fd79e0f036462eaf07d0aa95bb74ff432f9afef97ce1926c720988c6a7451d8a584930ae7de86e1 - languageName: node - linkType: hard - -"path-scurry@npm:^1.11.1": - version: 1.11.1 - resolution: "path-scurry@npm:1.11.1" - dependencies: - lru-cache: "npm:^10.2.0" - minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" - checksum: 10c0/32a13711a2a505616ae1cc1b5076801e453e7aae6ac40ab55b388bb91b9d0547a52f5aaceff710ea400205f18691120d4431e520afbe4266b836fadede15872d - languageName: node - linkType: hard - -"path-scurry@npm:^2.0.2": - version: 2.0.2 - resolution: "path-scurry@npm:2.0.2" - dependencies: - lru-cache: "npm:^11.0.0" - minipass: "npm:^7.1.2" - checksum: 10c0/b35ad37cf6557a87fd057121ce2be7695380c9138d93e87ae928609da259ea0a170fac6f3ef1eb3ece8a068e8b7f2f3adf5bb2374cf4d4a57fe484954fcc9482 - languageName: node - linkType: hard - -"pathval@npm:^2.0.0": - version: 2.0.0 - resolution: "pathval@npm:2.0.0" - checksum: 10c0/602e4ee347fba8a599115af2ccd8179836a63c925c23e04bd056d0674a64b39e3a081b643cc7bc0b84390517df2d800a46fcc5598d42c155fe4977095c2f77c5 - languageName: node - linkType: hard - -"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1": - version: 1.0.1 - resolution: "picocolors@npm:1.0.1" - checksum: 10c0/c63cdad2bf812ef0d66c8db29583802355d4ca67b9285d846f390cc15c2f6ccb94e8cb7eb6a6e97fc5990a6d3ad4ae42d86c84d3146e667c739a4234ed50d400 - languageName: node - linkType: hard - -"picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": - version: 1.1.1 - resolution: "picocolors@npm:1.1.1" - checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 - languageName: node - linkType: hard - -"picomatch@npm:^2.3.1": - version: 2.3.2 - resolution: "picomatch@npm:2.3.2" - checksum: 10c0/a554d1709e59be97d1acb9eaedbbc700a5c03dbd4579807baed95100b00420bc729335440ef15004ae2378984e2487a7c1cebd743cfdb72b6fa9ab69223c0d61 - languageName: node - linkType: hard - -"picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 - languageName: node - linkType: hard - -"picomatch@npm:^4.0.4": - version: 4.0.4 - resolution: "picomatch@npm:4.0.4" - checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 - languageName: node - linkType: hard - -"postcss@npm:^8.5.10": - version: 8.5.14 - resolution: "postcss@npm:8.5.14" - dependencies: - nanoid: "npm:^3.3.11" - picocolors: "npm:^1.1.1" - source-map-js: "npm:^1.2.1" - checksum: 10c0/48138207cf5ef5581be1bfe2cb65ccfe0ac75e43888ba045afc8ed6043d7b56aeb3b9a9fe5b353ff554be943cd0cc15d826ccb991525159175971e5ee8ab0237 - languageName: node - linkType: hard - -"prelude-ls@npm:^1.2.1": - version: 1.2.1 - resolution: "prelude-ls@npm:1.2.1" - checksum: 10c0/b00d617431e7886c520a6f498a2e14c75ec58f6d93ba48c3b639cf241b54232d90daa05d83a9e9b9fef6baa63cb7e1e4602c2372fea5bc169668401eb127d0cd - languageName: node - linkType: hard - -"prettier@npm:3.8.3": - version: 3.8.3 - resolution: "prettier@npm:3.8.3" - bin: - prettier: bin/prettier.cjs - checksum: 10c0/754816fd7593eb80f6376d7476d463e832c38a12f32775a82683adb6e35b772b1f484d65f19401507b983a8c8a7cd5a4a9f12006bd56491e8f35503473f77473 - languageName: node - linkType: hard - -"proc-log@npm:^4.1.0, proc-log@npm:^4.2.0": - version: 4.2.0 - resolution: "proc-log@npm:4.2.0" - checksum: 10c0/17db4757c2a5c44c1e545170e6c70a26f7de58feb985091fb1763f5081cab3d01b181fb2dd240c9f4a4255a1d9227d163d5771b7e69c9e49a561692db865efb9 - languageName: node - linkType: hard - -"promise-retry@npm:^2.0.1": - version: 2.0.1 - resolution: "promise-retry@npm:2.0.1" - dependencies: - err-code: "npm:^2.0.2" - retry: "npm:^0.12.0" - checksum: 10c0/9c7045a1a2928094b5b9b15336dcd2a7b1c052f674550df63cc3f36cd44028e5080448175b6f6ca32b642de81150f5e7b1a98b728f15cb069f2dd60ac2616b96 - languageName: node - linkType: hard - -"punycode@npm:^2.1.0": - version: 2.3.1 - resolution: "punycode@npm:2.3.1" - checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 - languageName: node - linkType: hard - -"react-docgen-typescript@npm:^2.2.2": - version: 2.2.2 - resolution: "react-docgen-typescript@npm:2.2.2" - peerDependencies: - typescript: ">= 4.3.x" - checksum: 10c0/d31a061a21b5d4b67d4af7bc742541fd9e16254bd32861cd29c52565bc2175f40421a3550d52b6a6b0d0478e7cc408558eb0060a0bdd2957b02cfceeb0ee1e88 - languageName: node - linkType: hard - -"react-docgen@npm:^8.0.0": - version: 8.0.0 - resolution: "react-docgen@npm:8.0.0" - dependencies: - "@babel/core": "npm:^7.18.9" - "@babel/traverse": "npm:^7.18.9" - "@babel/types": "npm:^7.18.9" - "@types/babel__core": "npm:^7.18.0" - "@types/babel__traverse": "npm:^7.18.0" - "@types/doctrine": "npm:^0.0.9" - "@types/resolve": "npm:^1.20.2" - doctrine: "npm:^3.0.0" - resolve: "npm:^1.22.1" - strip-indent: "npm:^4.0.0" - checksum: 10c0/2e3c187bed074895ac3420910129f23b30fe8f7faf984cbf6e210dd3914fa03a910583c5a4c4564edbef7461c37dfd6cd967c3bfc5d83c6f8c02cacedda38014 - languageName: node - linkType: hard - -"react-docgen@npm:^8.0.2": - version: 8.0.2 - resolution: "react-docgen@npm:8.0.2" - dependencies: - "@babel/core": "npm:^7.28.0" - "@babel/traverse": "npm:^7.28.0" - "@babel/types": "npm:^7.28.2" - "@types/babel__core": "npm:^7.20.5" - "@types/babel__traverse": "npm:^7.20.7" - "@types/doctrine": "npm:^0.0.9" - "@types/resolve": "npm:^1.20.2" - doctrine: "npm:^3.0.0" - resolve: "npm:^1.22.1" - strip-indent: "npm:^4.0.0" - checksum: 10c0/25e2dd48957c52749cf44bdcf172f3b47d42d8bb8c51000bceb136ff018cbe0a78610d04f12d8bbb882df0d86884e8d05b1d7a1cc39586de356ef5bb9fceab71 - languageName: node - linkType: hard - -"react-dom@npm:^18.2.0": - version: 18.3.1 - resolution: "react-dom@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - scheduler: "npm:^0.23.2" - peerDependencies: - react: ^18.3.1 - checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85 - languageName: node - linkType: hard - -"react-refresh@npm:^0.18.0": - version: 0.18.0 - resolution: "react-refresh@npm:0.18.0" - checksum: 10c0/34a262f7fd803433a534f50deb27a148112a81adcae440c7d1cbae7ef14d21ea8f2b3d783e858cb7698968183b77755a38b4d4b5b1d79b4f4689c2f6d358fff2 - languageName: node - linkType: hard - -"react@npm:^18.2.0": - version: 18.3.1 - resolution: "react@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3 - languageName: node - linkType: hard - -"recast@npm:^0.23.5": - version: 0.23.9 - resolution: "recast@npm:0.23.9" - dependencies: - ast-types: "npm:^0.16.1" - esprima: "npm:~4.0.0" - source-map: "npm:~0.6.1" - tiny-invariant: "npm:^1.3.3" - tslib: "npm:^2.0.1" - checksum: 10c0/65d6e780351f0180ea4fe5c9593ac18805bf2b79977f5bedbbbf26f6d9b619ed0f6992c1bf9e06dd40fca1aea727ad6d62463cfb5d3a33342ee5a6e486305fe5 - languageName: node - linkType: hard - -"redent@npm:^3.0.0": - version: 3.0.0 - resolution: "redent@npm:3.0.0" - dependencies: - indent-string: "npm:^4.0.0" - strip-indent: "npm:^3.0.0" - checksum: 10c0/d64a6b5c0b50eb3ddce3ab770f866658a2b9998c678f797919ceb1b586bab9259b311407280bd80b804e2a7c7539b19238ae6a2a20c843f1a7fcff21d48c2eae - languageName: node - linkType: hard - -"resolve@npm:^1.22.1, resolve@npm:^1.22.8": - version: 1.22.8 - resolution: "resolve@npm:1.22.8" - dependencies: - is-core-module: "npm:^2.13.0" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10c0/07e179f4375e1fd072cfb72ad66d78547f86e6196c4014b31cb0b8bb1db5f7ca871f922d08da0fbc05b94e9fd42206f819648fa3b5b873ebbc8e1dc68fec433a - languageName: node - linkType: hard - -"resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin": - version: 1.22.8 - resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d" - dependencies: - is-core-module: "npm:^2.13.0" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10c0/0446f024439cd2e50c6c8fa8ba77eaa8370b4180f401a96abf3d1ebc770ac51c1955e12764cde449fde3fff480a61f84388e3505ecdbab778f4bef5f8212c729 - languageName: node - linkType: hard - -"retry@npm:^0.12.0": - version: 0.12.0 - resolution: "retry@npm:0.12.0" - checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe - languageName: node - linkType: hard - -"rolldown@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "rolldown@npm:1.0.0-rc.17" - dependencies: - "@oxc-project/types": "npm:=0.127.0" - "@rolldown/binding-android-arm64": "npm:1.0.0-rc.17" - "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.17" - "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.17" - "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.17" - "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.17" - "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.17" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.17" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.17" - "@rolldown/pluginutils": "npm:1.0.0-rc.17" - dependenciesMeta: - "@rolldown/binding-android-arm64": - optional: true - "@rolldown/binding-darwin-arm64": - optional: true - "@rolldown/binding-darwin-x64": - optional: true - "@rolldown/binding-freebsd-x64": - optional: true - "@rolldown/binding-linux-arm-gnueabihf": - optional: true - "@rolldown/binding-linux-arm64-gnu": - optional: true - "@rolldown/binding-linux-arm64-musl": - optional: true - "@rolldown/binding-linux-ppc64-gnu": - optional: true - "@rolldown/binding-linux-s390x-gnu": - optional: true - "@rolldown/binding-linux-x64-gnu": - optional: true - "@rolldown/binding-linux-x64-musl": - optional: true - "@rolldown/binding-openharmony-arm64": - optional: true - "@rolldown/binding-wasm32-wasi": - optional: true - "@rolldown/binding-win32-arm64-msvc": - optional: true - "@rolldown/binding-win32-x64-msvc": - optional: true - bin: - rolldown: bin/cli.mjs - checksum: 10c0/bb99abc62ece4e34edd06d2b8eb9ffb7194dc2f0465a4329bb106cbde3006a10f1575e3580b198b793341109a2109581aed623c537c12b0c3a4ba0d72169b2fb - languageName: node - linkType: hard - -"run-applescript@npm:^7.0.0": - version: 7.1.0 - resolution: "run-applescript@npm:7.1.0" - checksum: 10c0/ab826c57c20f244b2ee807704b1ef4ba7f566aa766481ae5922aac785e2570809e297c69afcccc3593095b538a8a77d26f2b2e9a1d9dffee24e0e039502d1a03 - languageName: node - linkType: hard - -"safer-buffer@npm:>= 2.1.2 < 3.0.0": - version: 2.1.2 - resolution: "safer-buffer@npm:2.1.2" - checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 - languageName: node - linkType: hard - -"scheduler@npm:^0.23.2": - version: 0.23.2 - resolution: "scheduler@npm:0.23.2" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78 - languageName: node - linkType: hard - -"semver@npm:^6.3.1": - version: 6.3.1 - resolution: "semver@npm:6.3.1" - bin: - semver: bin/semver.js - checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d - languageName: node - linkType: hard - -"semver@npm:^7.3.5": - version: 7.6.3 - resolution: "semver@npm:7.6.3" - bin: - semver: bin/semver.js - checksum: 10c0/88f33e148b210c153873cb08cfe1e281d518aaa9a666d4d148add6560db5cd3c582f3a08ccb91f38d5f379ead256da9931234ed122057f40bb5766e65e58adaf - languageName: node - linkType: hard - -"semver@npm:^7.7.3": - version: 7.7.3 - resolution: "semver@npm:7.7.3" - bin: - semver: bin/semver.js - checksum: 10c0/4afe5c986567db82f44c8c6faef8fe9df2a9b1d98098fc1721f57c696c4c21cebd572f297fc21002f81889492345b8470473bc6f4aff5fb032a6ea59ea2bc45e - languageName: node - linkType: hard - -"shebang-command@npm:^2.0.0": - version: 2.0.0 - resolution: "shebang-command@npm:2.0.0" - dependencies: - shebang-regex: "npm:^3.0.0" - checksum: 10c0/a41692e7d89a553ef21d324a5cceb5f686d1f3c040759c50aab69688634688c5c327f26f3ecf7001ebfd78c01f3c7c0a11a7c8bfd0a8bc9f6240d4f40b224e4e - languageName: node - linkType: hard - -"shebang-regex@npm:^3.0.0": - version: 3.0.0 - resolution: "shebang-regex@npm:3.0.0" - checksum: 10c0/1dbed0726dd0e1152a92696c76c7f06084eb32a90f0528d11acd764043aacf76994b2fb30aa1291a21bd019d6699164d048286309a278855ee7bec06cf6fb690 - languageName: node - linkType: hard - -"signal-exit@npm:^4.0.1": - version: 4.1.0 - resolution: "signal-exit@npm:4.1.0" - checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 - languageName: node - linkType: hard - -"smart-buffer@npm:^4.2.0": - version: 4.2.0 - resolution: "smart-buffer@npm:4.2.0" - checksum: 10c0/a16775323e1404dd43fabafe7460be13a471e021637bc7889468eb45ce6a6b207261f454e4e530a19500cc962c4cc5348583520843b363f4193cee5c00e1e539 - languageName: node - linkType: hard - -"socks-proxy-agent@npm:^8.0.3": - version: 8.0.4 - resolution: "socks-proxy-agent@npm:8.0.4" - dependencies: - agent-base: "npm:^7.1.1" - debug: "npm:^4.3.4" - socks: "npm:^2.8.3" - checksum: 10c0/345593bb21b95b0508e63e703c84da11549f0a2657d6b4e3ee3612c312cb3a907eac10e53b23ede3557c6601d63252103494caa306b66560f43af7b98f53957a - languageName: node - linkType: hard - -"socks@npm:^2.8.3": - version: 2.8.3 - resolution: "socks@npm:2.8.3" - dependencies: - ip-address: "npm:^9.0.5" - smart-buffer: "npm:^4.2.0" - checksum: 10c0/d54a52bf9325165770b674a67241143a3d8b4e4c8884560c4e0e078aace2a728dffc7f70150660f51b85797c4e1a3b82f9b7aa25e0a0ceae1a243365da5c51a7 - languageName: node - linkType: hard - -"source-map-js@npm:^1.2.1": - version: 1.2.1 - resolution: "source-map-js@npm:1.2.1" - checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf - languageName: node - linkType: hard - -"source-map@npm:~0.6.1": - version: 0.6.1 - resolution: "source-map@npm:0.6.1" - checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 - languageName: node - linkType: hard - -"sprintf-js@npm:^1.1.3": - version: 1.1.3 - resolution: "sprintf-js@npm:1.1.3" - checksum: 10c0/09270dc4f30d479e666aee820eacd9e464215cdff53848b443964202bf4051490538e5dd1b42e1a65cf7296916ca17640aebf63dae9812749c7542ee5f288dec - languageName: node - linkType: hard - -"ssri@npm:^10.0.0": - version: 10.0.6 - resolution: "ssri@npm:10.0.6" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/e5a1e23a4057a86a97971465418f22ea89bd439ac36ade88812dd920e4e61873e8abd6a9b72a03a67ef50faa00a2daf1ab745c5a15b46d03e0544a0296354227 - languageName: node - linkType: hard - -"storybook@npm:^10.3.6": - version: 10.3.6 - resolution: "storybook@npm:10.3.6" - dependencies: - "@storybook/global": "npm:^5.0.0" - "@storybook/icons": "npm:^2.0.1" - "@testing-library/jest-dom": "npm:^6.9.1" - "@testing-library/user-event": "npm:^14.6.1" - "@vitest/expect": "npm:3.2.4" - "@vitest/spy": "npm:3.2.4" - "@webcontainer/env": "npm:^1.1.1" - esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0" - open: "npm:^10.2.0" - recast: "npm:^0.23.5" - semver: "npm:^7.7.3" - use-sync-external-store: "npm:^1.5.0" - ws: "npm:^8.18.0" - peerDependencies: - prettier: ^2 || ^3 - vite-plus: ^0.1.15 - peerDependenciesMeta: - prettier: - optional: true - vite-plus: - optional: true - bin: - storybook: ./dist/bin/dispatcher.js - checksum: 10c0/ee6702667459ba2d49269ddd63a7281f17816fcdd4bacd338ed47a4a8e7ad65760c90d99f6b2dfdfd0a564bcfcab3c1ea05b50bf3aafc6b5b19a039a5da77870 - languageName: node - linkType: hard - -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": - version: 4.2.3 - resolution: "string-width@npm:4.2.3" - dependencies: - emoji-regex: "npm:^8.0.0" - is-fullwidth-code-point: "npm:^3.0.0" - strip-ansi: "npm:^6.0.1" - checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b - languageName: node - linkType: hard - -"string-width@npm:^5.0.1, string-width@npm:^5.1.2": - version: 5.1.2 - resolution: "string-width@npm:5.1.2" - dependencies: - eastasianwidth: "npm:^0.2.0" - emoji-regex: "npm:^9.2.2" - strip-ansi: "npm:^7.0.1" - checksum: 10c0/ab9c4264443d35b8b923cbdd513a089a60de339216d3b0ed3be3ba57d6880e1a192b70ae17225f764d7adbf5994e9bb8df253a944736c15a0240eff553c678ca - languageName: node - linkType: hard - -"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": - version: 6.0.1 - resolution: "strip-ansi@npm:6.0.1" - dependencies: - ansi-regex: "npm:^5.0.1" - checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 - languageName: node - linkType: hard - -"strip-ansi@npm:^7.0.1": - version: 7.1.0 - resolution: "strip-ansi@npm:7.1.0" - dependencies: - ansi-regex: "npm:^6.0.1" - checksum: 10c0/a198c3762e8832505328cbf9e8c8381de14a4fa50a4f9b2160138158ea88c0f5549fb50cb13c651c3088f47e63a108b34622ec18c0499b6c8c3a5ddf6b305ac4 - languageName: node - linkType: hard - -"strip-bom@npm:^3.0.0": - version: 3.0.0 - resolution: "strip-bom@npm:3.0.0" - checksum: 10c0/51201f50e021ef16672593d7434ca239441b7b760e905d9f33df6e4f3954ff54ec0e0a06f100d028af0982d6f25c35cd5cda2ce34eaebccd0250b8befb90d8f1 - languageName: node - linkType: hard - -"strip-indent@npm:^3.0.0": - version: 3.0.0 - resolution: "strip-indent@npm:3.0.0" - dependencies: - min-indent: "npm:^1.0.0" - checksum: 10c0/ae0deaf41c8d1001c5d4fbe16cb553865c1863da4fae036683b474fa926af9fc121e155cb3fc57a68262b2ae7d5b8420aa752c97a6428c315d00efe2a3875679 - languageName: node - linkType: hard - -"strip-indent@npm:^4.0.0": - version: 4.0.0 - resolution: "strip-indent@npm:4.0.0" - dependencies: - min-indent: "npm:^1.0.1" - checksum: 10c0/6b1fb4e22056867f5c9e7a6f3f45922d9a2436cac758607d58aeaac0d3b16ec40b1c43317de7900f1b8dd7a4107352fa47fb960f2c23566538c51e8585c8870e - languageName: node - linkType: hard - -"supports-color@npm:^5.3.0": - version: 5.5.0 - resolution: "supports-color@npm:5.5.0" - dependencies: - has-flag: "npm:^3.0.0" - checksum: 10c0/6ae5ff319bfbb021f8a86da8ea1f8db52fac8bd4d499492e30ec17095b58af11f0c55f8577390a749b1c4dde691b6a0315dab78f5f54c9b3d83f8fb5905c1c05 - languageName: node - linkType: hard - -"supports-preserve-symlinks-flag@npm:^1.0.0": - version: 1.0.0 - resolution: "supports-preserve-symlinks-flag@npm:1.0.0" - checksum: 10c0/6c4032340701a9950865f7ae8ef38578d8d7053f5e10518076e6554a9381fa91bd9c6850193695c141f32b21f979c985db07265a758867bac95de05f7d8aeb39 - languageName: node - linkType: hard - -"tar@npm:^6.1.11, tar@npm:^6.2.1": - version: 6.2.1 - resolution: "tar@npm:6.2.1" - dependencies: - chownr: "npm:^2.0.0" - fs-minipass: "npm:^2.0.0" - minipass: "npm:^5.0.0" - minizlib: "npm:^2.1.1" - mkdirp: "npm:^1.0.3" - yallist: "npm:^4.0.0" - checksum: 10c0/a5eca3eb50bc11552d453488344e6507156b9193efd7635e98e867fab275d527af53d8866e2370cd09dfe74378a18111622ace35af6a608e5223a7d27fe99537 - languageName: node - linkType: hard - -"tiny-invariant@npm:^1.3.3": - version: 1.3.3 - resolution: "tiny-invariant@npm:1.3.3" - checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a - languageName: node - linkType: hard - -"tinyglobby@npm:^0.2.15": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" - dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 - languageName: node - linkType: hard - -"tinyglobby@npm:^0.2.16": - version: 0.2.16 - resolution: "tinyglobby@npm:0.2.16" - dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.4" - checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b - languageName: node - linkType: hard - -"tinyrainbow@npm:^2.0.0": - version: 2.0.0 - resolution: "tinyrainbow@npm:2.0.0" - checksum: 10c0/c83c52bef4e0ae7fb8ec6a722f70b5b6fa8d8be1c85792e829f56c0e1be94ab70b293c032dc5048d4d37cfe678f1f5babb04bdc65fd123098800148ca989184f - languageName: node - linkType: hard - -"tinyspy@npm:^4.0.3": - version: 4.0.3 - resolution: "tinyspy@npm:4.0.3" - checksum: 10c0/0a92a18b5350945cc8a1da3a22c9ad9f4e2945df80aaa0c43e1b3a3cfb64d8501e607ebf0305e048e3c3d3e0e7f8eb10cea27dc17c21effb73e66c4a3be36373 - languageName: node - linkType: hard - -"to-fast-properties@npm:^2.0.0": - version: 2.0.0 - resolution: "to-fast-properties@npm:2.0.0" - checksum: 10c0/b214d21dbfb4bce3452b6244b336806ffea9c05297148d32ebb428d5c43ce7545bdfc65a1ceb58c9ef4376a65c0cb2854d645f33961658b3e3b4f84910ddcdd7 - languageName: node - linkType: hard - -"ts-api-utils@npm:^2.4.0": - version: 2.4.0 - resolution: "ts-api-utils@npm:2.4.0" - peerDependencies: - typescript: ">=4.8.4" - checksum: 10c0/ed185861aef4e7124366a3f6561113557a57504267d4d452a51e0ba516a9b6e713b56b4aeaab9fa13de9db9ab755c65c8c13a777dba9133c214632cb7b65c083 - languageName: node - linkType: hard - -"ts-api-utils@npm:^2.5.0": - version: 2.5.0 - resolution: "ts-api-utils@npm:2.5.0" - peerDependencies: - typescript: ">=4.8.4" - checksum: 10c0/767849383c114e7f1971fa976b20e73ac28fd0c70d8d65c0004790bf4d8f89888c7e4cf6d5949f9c1beae9bc3c64835bef77bbe27fddf45a3c7b60cebcf85c8c - languageName: node - linkType: hard - -"ts-dedent@npm:^2.0.0": - version: 2.2.0 - resolution: "ts-dedent@npm:2.2.0" - checksum: 10c0/175adea838468cc2ff7d5e97f970dcb798bbcb623f29c6088cb21aa2880d207c5784be81ab1741f56b9ac37840cbaba0c0d79f7f8b67ffe61c02634cafa5c303 - languageName: node - linkType: hard - -"tsafe@npm:^1.8.5": - version: 1.8.5 - resolution: "tsafe@npm:1.8.5" - checksum: 10c0/5eb88061c640a11035aa8f7d9713cf9d1e9d479b417ed5f7094151d891b76160d17dd3a8718d624b95a77240e1221fd2a6b6064a25447ca9983088f92442450b - languageName: node - linkType: hard - -"tsconfig-paths@npm:^4.2.0": - version: 4.2.0 - resolution: "tsconfig-paths@npm:4.2.0" - dependencies: - json5: "npm:^2.2.2" - minimist: "npm:^1.2.6" - strip-bom: "npm:^3.0.0" - checksum: 10c0/09a5877402d082bb1134930c10249edeebc0211f36150c35e1c542e5b91f1047b1ccf7da1e59babca1ef1f014c525510f4f870de7c9bda470c73bb4e2721b3ea - languageName: node - linkType: hard - -"tslib@npm:^2.0.1": - version: 2.6.3 - resolution: "tslib@npm:2.6.3" - checksum: 10c0/2598aef53d9dbe711af75522464b2104724d6467b26a60f2bdac8297d2b5f1f6b86a71f61717384aa8fd897240467aaa7bcc36a0700a0faf751293d1331db39a - languageName: node - linkType: hard - -"tslib@npm:^2.4.0": - version: 2.8.1 - resolution: "tslib@npm:2.8.1" - checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 - languageName: node - linkType: hard - -"type-check@npm:^0.4.0, type-check@npm:~0.4.0": - version: 0.4.0 - resolution: "type-check@npm:0.4.0" - dependencies: - prelude-ls: "npm:^1.2.1" - checksum: 10c0/7b3fd0ed43891e2080bf0c5c504b418fbb3e5c7b9708d3d015037ba2e6323a28152ec163bcb65212741fa5d2022e3075ac3c76440dbd344c9035f818e8ecee58 - languageName: node - linkType: hard - -"typescript@npm:^5.2.2": - version: 5.4.5 - resolution: "typescript@npm:5.4.5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/2954022ada340fd3d6a9e2b8e534f65d57c92d5f3989a263754a78aba549f7e6529acc1921913560a4b816c46dce7df4a4d29f9f11a3dc0d4213bb76d043251e - languageName: node - linkType: hard - -"typescript@patch:typescript@npm%3A^5.2.2#optional!builtin": - version: 5.4.5 - resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/db2ad2a16ca829f50427eeb1da155e7a45e598eec7b086d8b4e8ba44e5a235f758e606d681c66992230d3fc3b8995865e5fd0b22a2c95486d0b3200f83072ec9 - languageName: node - linkType: hard - -"unique-filename@npm:^3.0.0": - version: 3.0.0 - resolution: "unique-filename@npm:3.0.0" - dependencies: - unique-slug: "npm:^4.0.0" - checksum: 10c0/6363e40b2fa758eb5ec5e21b3c7fb83e5da8dcfbd866cc0c199d5534c42f03b9ea9ab069769cc388e1d7ab93b4eeef28ef506ab5f18d910ef29617715101884f - languageName: node - linkType: hard - -"unique-slug@npm:^4.0.0": - version: 4.0.0 - resolution: "unique-slug@npm:4.0.0" - dependencies: - imurmurhash: "npm:^0.1.4" - checksum: 10c0/cb811d9d54eb5821b81b18205750be84cb015c20a4a44280794e915f5a0a70223ce39066781a354e872df3572e8155c228f43ff0cce94c7cbf4da2cc7cbdd635 - languageName: node - linkType: hard - -"unplugin@npm:^2.3.5": - version: 2.3.11 - resolution: "unplugin@npm:2.3.11" - dependencies: - "@jridgewell/remapping": "npm:^2.3.5" - acorn: "npm:^8.15.0" - picomatch: "npm:^4.0.3" - webpack-virtual-modules: "npm:^0.6.2" - checksum: 10c0/273c1eab0eca4470c7317428689295c31dbe8ab0b306504de9f03cd20c156debb4131bef24b27ac615862958c5dd950a3951d26c0723ea774652ab3624149cff - languageName: node - linkType: hard - -"update-browserslist-db@npm:^1.0.16": - version: 1.0.16 - resolution: "update-browserslist-db@npm:1.0.16" - dependencies: - escalade: "npm:^3.1.2" - picocolors: "npm:^1.0.1" - peerDependencies: - browserslist: ">= 4.21.0" - bin: - update-browserslist-db: cli.js - checksum: 10c0/5995399fc202adbb51567e4810e146cdf7af630a92cc969365a099150cb00597e425cc14987ca7080b09a4d0cfd2a3de53fbe72eebff171aed7f9bb81f9bf405 - languageName: node - linkType: hard - -"update-browserslist-db@npm:^1.1.1": - version: 1.1.1 - resolution: "update-browserslist-db@npm:1.1.1" - dependencies: - escalade: "npm:^3.2.0" - picocolors: "npm:^1.1.0" - peerDependencies: - browserslist: ">= 4.21.0" - bin: - update-browserslist-db: cli.js - checksum: 10c0/536a2979adda2b4be81b07e311bd2f3ad5e978690987956bc5f514130ad50cac87cd22c710b686d79731e00fbee8ef43efe5fcd72baa241045209195d43dcc80 - languageName: node - linkType: hard - -"uri-js@npm:^4.2.2": - version: 4.4.1 - resolution: "uri-js@npm:4.4.1" - dependencies: - punycode: "npm:^2.1.0" - checksum: 10c0/4ef57b45aa820d7ac6496e9208559986c665e49447cb072744c13b66925a362d96dd5a46c4530a6b8e203e5db5fe849369444440cb22ecfc26c679359e5dfa3c - languageName: node - linkType: hard - -"use-sync-external-store@npm:^1.5.0": - version: 1.6.0 - resolution: "use-sync-external-store@npm:1.6.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b - languageName: node - linkType: hard - -"vite@npm:^8.0.10": - version: 8.0.10 - resolution: "vite@npm:8.0.10" - dependencies: - fsevents: "npm:~2.3.3" - lightningcss: "npm:^1.32.0" - picomatch: "npm:^4.0.4" - postcss: "npm:^8.5.10" - rolldown: "npm:1.0.0-rc.17" - tinyglobby: "npm:^0.2.16" - peerDependencies: - "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.1.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: ">=1.21.0" - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: ">=0.54.8" - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@types/node": - optional: true - "@vitejs/devtools": - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - bin: - vite: bin/vite.js - checksum: 10c0/92188b82654f856dbe562a1b679de695bb6ca18c0f43c4c276f84a869fb78e22dedb7c2df83b5617d6afdca979c059d654b5f61a0936a45f49917f352b9325ca - languageName: node - linkType: hard - -"webpack-virtual-modules@npm:^0.6.2": - version: 0.6.2 - resolution: "webpack-virtual-modules@npm:0.6.2" - checksum: 10c0/5ffbddf0e84bf1562ff86cf6fcf039c74edf09d78358a6904a09bbd4484e8bb6812dc385fe14330b715031892dcd8423f7a88278b57c9f5002c84c2860179add - languageName: node - linkType: hard - -"which@npm:^2.0.1": - version: 2.0.2 - resolution: "which@npm:2.0.2" - dependencies: - isexe: "npm:^2.0.0" - bin: - node-which: ./bin/node-which - checksum: 10c0/66522872a768b60c2a65a57e8ad184e5372f5b6a9ca6d5f033d4b0dc98aff63995655a7503b9c0a2598936f532120e81dd8cc155e2e92ed662a2b9377cc4374f - languageName: node - linkType: hard - -"which@npm:^4.0.0": - version: 4.0.0 - resolution: "which@npm:4.0.0" - dependencies: - isexe: "npm:^3.1.1" - bin: - node-which: bin/which.js - checksum: 10c0/449fa5c44ed120ccecfe18c433296a4978a7583bf2391c50abce13f76878d2476defde04d0f79db8165bdf432853c1f8389d0485ca6e8ebce3bbcded513d5e6a - languageName: node - linkType: hard - -"word-wrap@npm:^1.2.5": - version: 1.2.5 - resolution: "word-wrap@npm:1.2.5" - checksum: 10c0/e0e4a1ca27599c92a6ca4c32260e8a92e8a44f4ef6ef93f803f8ed823f486e0889fc0b93be4db59c8d51b3064951d25e43d434e95dc8c960cc3a63d65d00ba20 - languageName: node - linkType: hard - -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version: 7.0.0 - resolution: "wrap-ansi@npm:7.0.0" - dependencies: - ansi-styles: "npm:^4.0.0" - string-width: "npm:^4.1.0" - strip-ansi: "npm:^6.0.0" - checksum: 10c0/d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da - languageName: node - linkType: hard - -"wrap-ansi@npm:^8.1.0": - version: 8.1.0 - resolution: "wrap-ansi@npm:8.1.0" - dependencies: - ansi-styles: "npm:^6.1.0" - string-width: "npm:^5.0.1" - strip-ansi: "npm:^7.0.1" - checksum: 10c0/138ff58a41d2f877eae87e3282c0630fc2789012fc1af4d6bd626eeb9a2f9a65ca92005e6e69a75c7b85a68479fe7443c7dbe1eb8fbaa681a4491364b7c55c60 - languageName: node - linkType: hard - -"ws@npm:^8.18.0": - version: 8.18.2 - resolution: "ws@npm:8.18.2" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/4b50f67931b8c6943c893f59c524f0e4905bbd183016cfb0f2b8653aa7f28dad4e456b9d99d285bbb67cca4fedd9ce90dfdfaa82b898a11414ebd66ee99141e4 - languageName: node - linkType: hard - -"wsl-utils@npm:^0.1.0": - version: 0.1.0 - resolution: "wsl-utils@npm:0.1.0" - dependencies: - is-wsl: "npm:^3.1.0" - checksum: 10c0/44318f3585eb97be994fc21a20ddab2649feaf1fbe893f1f866d936eea3d5f8c743bec6dc02e49fbdd3c0e69e9b36f449d90a0b165a4f47dd089747af4cf2377 - languageName: node - linkType: hard - -"yallist@npm:^3.0.2": - version: 3.1.1 - resolution: "yallist@npm:3.1.1" - checksum: 10c0/c66a5c46bc89af1625476f7f0f2ec3653c1a1791d2f9407cfb4c2ba812a1e1c9941416d71ba9719876530e3340a99925f697142989371b72d93b9ee628afd8c1 - languageName: node - linkType: hard - -"yallist@npm:^4.0.0": - version: 4.0.0 - resolution: "yallist@npm:4.0.0" - checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a - languageName: node - linkType: hard - -"yocto-queue@npm:^0.1.0": - version: 0.1.0 - resolution: "yocto-queue@npm:0.1.0" - checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f - languageName: node - linkType: hard - -"zod-validation-error@npm:^3.5.0 || ^4.0.0": - version: 4.0.2 - resolution: "zod-validation-error@npm:4.0.2" - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - checksum: 10c0/0ccfec48c46de1be440b719cd02044d4abb89ed0e14c13e637cd55bf29102f67ccdba373f25def0fc7130e5f15025be4d557a7edcc95d5a3811599aade689e1b - languageName: node - linkType: hard - -"zod@npm:^3.25.0 || ^4.0.0": - version: 4.1.12 - resolution: "zod@npm:4.1.12" - checksum: 10c0/b64c1feb19e99d77075261eaf613e0b2be4dfcd3551eff65ad8b4f2a079b61e379854d066f7d447491fcf193f45babd8095551a9d47973d30b46b6d8e2c46774 - languageName: node - linkType: hard diff --git a/kubernetes/loculus/templates/_common-metadata.tpl b/kubernetes/loculus/templates/_common-metadata.tpl index 147e463c77..c7ea72e6d0 100644 --- a/kubernetes/loculus/templates/_common-metadata.tpl +++ b/kubernetes/loculus/templates/_common-metadata.tpl @@ -205,8 +205,8 @@ bannerMessageURL: {{ quote $.Values.bannerMessageURL }} {{ end }} {{ if $.Values.bannerMessage }} bannerMessage: {{ quote $.Values.bannerMessage }} -{{ else if or $.Values.runDevelopmentMainDatabase $.Values.runDevelopmentKeycloakDatabase }} -bannerMessage: "Warning: Development or Keycloak main database is enabled. Development environment only." +{{ else if $.Values.runDevelopmentMainDatabase }} +bannerMessage: "Warning: Development main database is enabled. Development environment only." {{ end }} {{ if $.Values.submissionBannerMessageURL }} submissionBannerMessageURL: {{ quote $.Values.submissionBannerMessageURL }} @@ -621,7 +621,8 @@ fields: {{- $externalLapisUrlConfig := dict "lapisUrlTemplate" $lapisUrlTemplate "config" $.Values }} "backendUrl": "{{ include "loculus.backendUrl" . }}", "lapisUrls": {{- include "loculus.generateExternalLapisUrls" $externalLapisUrlConfig | fromYaml | toJson }}, - "keycloakUrl": "{{ include "loculus.keycloakUrl" . }}" + "autheliaUrl": "{{ include "loculus.autheliaUrl" . }}", + "registrationUrl": "{{ include "loculus.registrationUrl" . }}" {{- end }} diff --git a/kubernetes/loculus/templates/_config-processor.tpl b/kubernetes/loculus/templates/_config-processor.tpl index cfea6861d9..88451a92d0 100644 --- a/kubernetes/loculus/templates/_config-processor.tpl +++ b/kubernetes/loculus/templates/_config-processor.tpl @@ -42,16 +42,41 @@ secretKeyRef: name: service-accounts key: backendUserPassword - - name: LOCULUSSUB_backendKeycloakClientSecret + - name: LOCULUSSUB_lldapAdminPassword valueFrom: secretKeyRef: - name: backend-keycloak-client-secret - key: backendKeycloakClientSecret - - name: LOCULUSSUB_orcidSecret + name: lldap-secrets + key: adminPassword + - name: LOCULUSSUB_autheliaSessionSecret valueFrom: secretKeyRef: - name: orcid - key: orcidSecret + name: authelia-secrets + key: sessionSecret + - name: LOCULUSSUB_autheliaStorageEncryptionKey + valueFrom: + secretKeyRef: + name: authelia-secrets + key: storageEncryptionKey + - name: LOCULUSSUB_autheliaJwtSecret + valueFrom: + secretKeyRef: + name: authelia-secrets + key: jwtSecret + - name: LOCULUSSUB_autheliaOidcHmacSecret + valueFrom: + secretKeyRef: + name: authelia-secrets + key: oidcHmacSecret + - name: LOCULUSSUB_backendClientSecret + valueFrom: + secretKeyRef: + name: authelia-secrets + key: backendClientSecretHash + - name: LOCULUSSUB_backendClientSecretPlain + valueFrom: + secretKeyRef: + name: authelia-secrets + key: backendClientSecretPlain {{- end }} diff --git a/kubernetes/loculus/templates/_urls.tpl b/kubernetes/loculus/templates/_urls.tpl index e1b609cfdd..5e36814a9f 100644 --- a/kubernetes/loculus/templates/_urls.tpl +++ b/kubernetes/loculus/templates/_urls.tpl @@ -40,17 +40,34 @@ {{- end -}} {{- end -}} -{{- define "loculus.keycloakUrl" -}} +{{- define "loculus.autheliaUrl" -}} {{- $publicRuntimeConfig := $.Values.public }} - {{- if $publicRuntimeConfig.keycloakUrl }} - {{- $publicRuntimeConfig.keycloakUrl -}} + {{- $hostStr := default "" $.Values.host -}} + {{- $hostNoPort := index (splitList ":" $hostStr) 0 -}} + {{- if $publicRuntimeConfig.autheliaUrl }} + {{- $publicRuntimeConfig.autheliaUrl -}} {{- else if eq $.Values.environment "server" -}} {{- (printf "https://authentication%s%s" $.Values.subdomainSeparator $.Values.host) -}} {{- else -}} - {{- printf "http://%s:8083" $.Values.localHost -}} + {{- /* dev: traefik terminates HTTPS on host port 8443 with a self-signed cert */ -}} + {{- printf "https://authentication%s%s:8443" $.Values.subdomainSeparator $hostNoPort -}} {{- end -}} {{- end -}} +{{- define "loculus.autheliaUrlInternal" -}} + {{- "http://loculus-authelia-service:9091" -}} +{{- end -}} + +{{- define "loculus.registrationUrl" -}} +{{- if .Values.auth.bundledLdap.enabled -}} + {{- if eq $.Values.environment "server" -}} + {{- (printf "https://register%s%s" $.Values.subdomainSeparator $.Values.host) -}} + {{- else -}} + {{- printf "http://%s:8090" $.Values.localHost -}} + {{- end -}} +{{- end -}} +{{- end -}} + {{/* generates internal LAPIS urls from given config object */}} {{ define "loculus.generateInternalLapisUrls" }} {{ range $_, $item := (include "loculus.enabledOrganisms" . | fromJson).organisms }} diff --git a/kubernetes/loculus/templates/authelia-configmap.yaml b/kubernetes/loculus/templates/authelia-configmap.yaml new file mode 100644 index 0000000000..72a86e0f21 --- /dev/null +++ b/kubernetes/loculus/templates/authelia-configmap.yaml @@ -0,0 +1,130 @@ +{{- /* `loculus.autheliaUrl` already returns HTTPS in both server and local modes (local = via traefik on 8443). */}} +{{- $authIssuer := (include "loculus.autheliaUrl" .) }} +{{- $authIssuerNoScheme := trimPrefix "http://" (trimPrefix "https://" $authIssuer) }} +{{- $authHostWithPort := index (splitList "/" $authIssuerNoScheme) 0 }} +{{- $authHostNoPort := index (splitList ":" $authHostWithPort) 0 }} +{{- $websiteUrl := (include "loculus.websiteUrl" .) }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: authelia-config +data: + configuration.yml: | + server: + address: "tcp://:9091" + buffers: + read: 4096 + write: 4096 + {{- if $.Values.auth.relaxOidcTokenRateLimit }} + endpoints: + rate_limits: + openid_connect_token: + enable: true + buckets: + - period: "1 minute" + requests: 10000 + - period: "1 hour" + requests: 100000 + {{- end }} + + log: + level: info + format: text + + totp: + issuer: "{{ $.Values.name }}" + disable: false + + authentication_backend: + refresh_interval: 1m + ldap: + implementation: lldap + address: ldap://loculus-lldap-service:3890 + base_dn: dc=loculus,dc=org + additional_users_dn: ou=people + additional_groups_dn: ou=groups + users_filter: "(&({username_attribute}={input})(objectClass=person))" + groups_filter: "(member={dn})" + user: "uid=admin,ou=people,dc=loculus,dc=org" + password: "[[lldapAdminPassword]]" + + access_control: + default_policy: one_factor + + session: + name: authelia_session + same_site: lax + inactivity: 1h + expiration: 1h + remember_me: 1M + cookies: + - domain: {{ $authHostNoPort | quote }} + authelia_url: "{{ $authIssuer }}" + secret: "[[autheliaSessionSecret]]" + + storage: + encryption_key: "[[autheliaStorageEncryptionKey]]" + local: + path: /data/db.sqlite3 + + notifier: + disable_startup_check: true + filesystem: + filename: /data/notification.txt + + identity_validation: + reset_password: + jwt_secret: "[[autheliaJwtSecret]]" + + identity_providers: + oidc: + hmac_secret: "[[autheliaOidcHmacSecret]]" + jwks: + - key: | +{{- (index $.Values.secrets "authelia-secrets" "data" "oidcIssuerPrivateKey") | nindent 14 }} + lifespans: + access_token: 10h + authorize_code: 1m + id_token: 1h + refresh_token: 90d + claims_policies: + loculus_backend: + id_token: [preferred_username, groups, email, name] + cors: + endpoints: [authorization, token, revocation, introspection, userinfo] + allowed_origins_from_client_redirect_uris: true + clients: + - client_id: backend-client + client_name: Loculus website + # Authelia 4.39 requires http:// redirect URIs to belong to a + # confidential client (one with a `client_secret`). The dev/CI + # setup runs the website on http://loculus.test:3000, so the + # client is confidential with a static dev-only PHC hash for the + # secret "loculus-dev-client-secret". Operators rotate at deploy. + public: false + client_secret: "[[backendClientSecret]]" + authorization_policy: one_factor + require_pkce: true + pkce_challenge_method: S256 + redirect_uris: + - "{{ $websiteUrl }}/auth/callback" + {{- if $.Values.host }} + - "http://{{ $.Values.host }}/auth/callback" + - "https://{{ $.Values.host }}/auth/callback" + {{- end }} + - "http://localhost:3000/auth/callback" + scopes: [openid, profile, email, groups, offline_access] + grant_types: [authorization_code, refresh_token] + response_types: [code] + consent_mode: implicit + claims_policy: loculus_backend + token_endpoint_auth_method: client_secret_basic + - client_id: loculus-cli + client_name: Loculus CLI + public: true + authorization_policy: one_factor + scopes: [openid, profile, email, groups, offline_access] + grant_types: [urn:ietf:params:oauth:grant-type:device_code, refresh_token] + response_types: [code] + consent_mode: implicit + token_endpoint_auth_method: none diff --git a/kubernetes/loculus/templates/authelia-deployment.yaml b/kubernetes/loculus/templates/authelia-deployment.yaml new file mode 100644 index 0000000000..31c92c1d51 --- /dev/null +++ b/kubernetes/loculus/templates/authelia-deployment.yaml @@ -0,0 +1,60 @@ +--- +{{- $dockerTag := include "loculus.dockerTag" .Values }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: loculus-authelia + annotations: + argocd.argoproj.io/sync-options: Replace=true +spec: + replicas: 1 + selector: + matchLabels: + app: loculus + component: authelia + template: + metadata: + annotations: + timestamp: {{ now | quote }} + labels: + app: loculus + component: authelia + spec: + {{- include "possiblePriorityClassName" . | nindent 6 }} + initContainers: +{{- include "loculus.configProcessor" (dict "name" "authelia-config" "dockerTag" $dockerTag "imagePullPolicy" .Values.imagePullPolicy) | nindent 8 }} + containers: + - name: authelia + image: "authelia/authelia:4.39" + {{- include "loculus.resources" (list "authelia" $.Values) | nindent 10 }} + ports: + - containerPort: 9091 + volumeMounts: + - name: authelia-config-processed + mountPath: /config + - name: authelia-oidc-key + mountPath: /secrets + readOnly: true + - name: data + mountPath: /data + startupProbe: + httpGet: + path: /api/health + port: 9091 + failureThreshold: 60 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /api/health + port: 9091 + periodSeconds: 30 + volumes: +{{ include "loculus.configVolume" (dict "name" "authelia-config") | nindent 8 }} + - name: authelia-oidc-key + secret: + secretName: authelia-secrets + items: + - key: oidcIssuerPrivateKey + path: oidc-issuer.pem + - name: data + emptyDir: {} diff --git a/kubernetes/loculus/templates/keycloak-service.yaml b/kubernetes/loculus/templates/authelia-service.yaml similarity index 65% rename from kubernetes/loculus/templates/keycloak-service.yaml rename to kubernetes/loculus/templates/authelia-service.yaml index a0a4077a16..f6f4adf307 100644 --- a/kubernetes/loculus/templates/keycloak-service.yaml +++ b/kubernetes/loculus/templates/authelia-service.yaml @@ -1,17 +1,17 @@ apiVersion: v1 kind: Service metadata: - name: loculus-keycloak-service + name: loculus-authelia-service spec: {{- template "loculus.serviceType" . }} selector: app: loculus - component: keycloak + component: authelia ports: - - port: 8083 - targetPort: 8080 + - port: 9091 + targetPort: 9091 {{- if ne $.Values.environment "server" }} - nodePort: 30083 + nodePort: 30091 {{- end }} protocol: TCP name: http diff --git a/kubernetes/loculus/templates/autoapprove-config.yaml b/kubernetes/loculus/templates/autoapprove-config.yaml index a69ce135cd..958c85a537 100644 --- a/kubernetes/loculus/templates/autoapprove-config.yaml +++ b/kubernetes/loculus/templates/autoapprove-config.yaml @@ -1,7 +1,7 @@ {{- if not .Values.disableIngest }} {{- $testconfig := .Values.testconfig | default false }} {{- $backendHost := .Values.environment | eq "server" | ternary (printf "https://backend%s%s" .Values.subdomainSeparator $.Values.host) ($testconfig | ternary (printf "http://%s:8079" $.Values.localHost) "http://loculus-backend-service:8079") }} -{{- $keycloakHost := $testconfig | ternary (printf "http://%s:8083" $.Values.localHost) "http://loculus-keycloak-service:8083" }} +{{- $keycloakHost := $testconfig | ternary (printf "http://%s:9091" $.Values.localHost) "http://loculus-authelia-service:9091" }} {{- $organismKeys := list }} {{- range $_, $item := (include "loculus.enabledOrganisms" . | fromJson).organisms }} {{- if $item.contents.ingest }} @@ -17,5 +17,5 @@ data: config.yaml: | organisms: {{ $organismKeys | toJson }} backend_url: {{ $backendHost }} - keycloak_token_url: {{ $keycloakHost -}}/realms/loculus/protocol/openid-connect/token + keycloak_token_url: {{ $keycloakHost -}}/api/oidc/token {{- end }} diff --git a/kubernetes/loculus/templates/ena-submission-config.yaml b/kubernetes/loculus/templates/ena-submission-config.yaml index c1fe71a408..76f1e05f10 100644 --- a/kubernetes/loculus/templates/ena-submission-config.yaml +++ b/kubernetes/loculus/templates/ena-submission-config.yaml @@ -2,7 +2,7 @@ {{- $testconfig := .Values.testconfig | default false }} {{- $enaDepositionHost := $testconfig | ternary "127.0.0.1" "0.0.0.0" }} {{- $backendHost := .Values.environment | eq "server" | ternary (printf "https://backend%s%s" .Values.subdomainSeparator $.Values.host) ($testconfig | ternary (printf "http://%s:8079" $.Values.localHost) "http://loculus-backend-service:8079") }} -{{- $keycloakHost := $testconfig | ternary (printf "http://%s:8083" $.Values.localHost) "http://loculus-keycloak-service:8083" }} +{{- $keycloakHost := $testconfig | ternary (printf "http://%s:9091" $.Values.localHost) "http://loculus-authelia-service:9091" }} {{- $submitToEnaProduction := .Values.enaDeposition.submitToEnaProduction | default false }} {{- $enaDbName := .Values.enaDeposition.enaDbName | default false }} {{- $enaUniqueSuffix := .Values.enaDeposition.enaUniqueSuffix | default false }} @@ -22,7 +22,7 @@ data: unique_project_suffix: {{ $enaUniqueSuffix }} backend_url: {{ $backendHost }} ena_deposition_host: {{ $enaDepositionHost }} - keycloak_token_url: {{ $keycloakHost -}}/realms/loculus/protocol/openid-connect/token + keycloak_token_url: {{ $keycloakHost -}}/api/oidc/token approved_list_test_url: {{ $enaApprovedListTestUrl }} suppressed_list_test_url: {{ $enaSuppressedListTestUrl }} {{- include "loculus.generateENASubmissionConfig" . | nindent 4 }} diff --git a/kubernetes/loculus/templates/ingest-config.yaml b/kubernetes/loculus/templates/ingest-config.yaml index e8ff5dd135..b15ad6d348 100644 --- a/kubernetes/loculus/templates/ingest-config.yaml +++ b/kubernetes/loculus/templates/ingest-config.yaml @@ -2,7 +2,7 @@ {{- $testconfig := .Values.testconfig | default false }} {{- $backendHost := .Values.environment | eq "server" | ternary (printf "https://backend%s%s" .Values.subdomainSeparator $.Values.host) ($testconfig | ternary (printf "http://%s:8079" $.Values.localHost) "http://loculus-backend-service:8079") }} {{- $enaDepositionHost := $testconfig | ternary (printf "http://%s:5000" $.Values.localHost) "http://loculus-ena-submission-service:5000" }} -{{- $keycloakHost := $testconfig | ternary (printf "http://%s:8083" $.Values.localHost) "http://loculus-keycloak-service:8083" }} +{{- $keycloakHost := $testconfig | ternary (printf "http://%s:9091" $.Values.localHost) "http://loculus-authelia-service:9091" }} {{- range $_, $item := (include "loculus.enabledOrganisms" . | fromJson).organisms }} {{- $key := $item.key }} {{- $values := $item.contents }} @@ -25,7 +25,7 @@ data: {{- end }} organism: {{ $key }} backend_url: {{ $backendHost }} - keycloak_token_url: {{ $keycloakHost -}}/realms/loculus/protocol/openid-connect/token + keycloak_token_url: {{ $keycloakHost -}}/api/oidc/token {{- if $.Values.ingest.ncbiGatewayUrl }} ncbi_gateway_url: {{ $.Values.ingest.ncbiGatewayUrl }} {{- end }} diff --git a/kubernetes/loculus/templates/ingressroute.yaml b/kubernetes/loculus/templates/ingressroute.yaml index 5fbd578490..8f680c0331 100644 --- a/kubernetes/loculus/templates/ingressroute.yaml +++ b/kubernetes/loculus/templates/ingressroute.yaml @@ -46,11 +46,15 @@ spec: replacement: "https://$1" permanent: true --- -{{- if eq $.Values.environment "server" }} -{{- $backendHost := printf "backend%s%s" .Values.subdomainSeparator .Values.host }} -{{- $keycloakHost := (printf "authentication%s%s" $.Values.subdomainSeparator $.Values.host) }} -{{- $minioHost := (printf "s3%s%s" $.Values.subdomainSeparator $.Values.host) }} +{{- /* Ingress rules require bare hostnames (no port). */}} +{{- $hostBare := index (splitList ":" (default "" $.Values.host)) 0 }} +{{- $backendHost := printf "backend%s%s" .Values.subdomainSeparator $hostBare }} +{{- $keycloakHost := (printf "authentication%s%s" $.Values.subdomainSeparator $hostBare) }} +{{- $minioHost := (printf "s3%s%s" $.Values.subdomainSeparator $hostBare) }} +{{- $registerHost := (printf "register%s%s" $.Values.subdomainSeparator $hostBare) }} {{- $middlewareList := list (printf "%s-compression-middleware@kubernetescrd" $.Release.Namespace) }} +{{- $middlewareListForKeycloak := $middlewareList }} +{{- if eq $.Values.environment "server" }} {{- if $.Values.enforceHTTPS }} {{- $middlewareList = append $middlewareList (printf "%s-redirect-middleware@kubernetescrd" $.Release.Namespace) }} {{- end }} @@ -59,7 +63,6 @@ spec: {{ end }} {{ $middlewareListForWebsite := $middlewareList }} -{{ $middlewareListForKeycloak := $middlewareList }} {{ if $.Values.secrets.basicauth }} {{ $middlewareListForWebsite = append $middlewareListForWebsite (printf "%s-basic-auth@kubernetescrd" $.Release.Namespace) }} @@ -75,8 +78,11 @@ metadata: annotations: traefik.ingress.kubernetes.io/router.middlewares: "{{ join "," $middlewareListForWebsite }}" spec: + {{- if $.Values.ingressClassName }} + ingressClassName: "{{ $.Values.ingressClassName }}" + {{- end }} rules: - - host: "{{ .Values.host }}" + - host: "{{ $hostBare }}" http: paths: - path: / @@ -86,7 +92,7 @@ spec: name: loculus-website-service port: number: 3000 - - host: "www.{{ .Values.host }}" + - host: "www.{{ $hostBare }}" http: paths: - path: / @@ -98,8 +104,8 @@ spec: number: 3000 tls: - hosts: - - "{{ .Values.host }}" - - "www.{{ .Values.host }}" + - "{{ $hostBare }}" + - "www.{{ $hostBare }}" --- apiVersion: networking.k8s.io/v1 kind: Ingress @@ -108,6 +114,9 @@ metadata: annotations: traefik.ingress.kubernetes.io/router.middlewares: "{{ join "," $middlewareList }}" spec: + {{- if $.Values.ingressClassName }} + ingressClassName: "{{ $.Values.ingressClassName }}" + {{- end }} rules: - host: "{{ $backendHost }}" http: @@ -122,14 +131,19 @@ spec: tls: - hosts: - "{{ $backendHost }}" +{{- end }} --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: loculus-keycloak-ingress + name: loculus-authelia-ingress annotations: traefik.ingress.kubernetes.io/router.middlewares: "{{ join "," $middlewareListForKeycloak }}" + traefik.ingress.kubernetes.io/router.priority: "1000" spec: + {{- if $.Values.ingressClassName }} + ingressClassName: "{{ $.Values.ingressClassName }}" + {{- end }} rules: - host: "{{ $keycloakHost }}" http: @@ -138,13 +152,59 @@ spec: pathType: Prefix backend: service: - name: loculus-keycloak-service + name: loculus-authelia-service port: - number: 8083 + number: 9091 tls: - hosts: - "{{ $keycloakHost }}" --- +apiVersion: {{ $traefikApiVersion }} +kind: IngressRoute +metadata: + name: loculus-authelia-ingressroute +spec: + routes: + - match: Host(`{{ $keycloakHost }}`) + kind: Rule + priority: 1000 + middlewares: + - name: compression-middleware + {{- if $.Values.secrets.basicauth }} + - name: basic-auth + {{- end }} + services: + - name: loculus-authelia-service + port: 9091 + tls: {} +{{- if .Values.auth.bundledLdap.enabled }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: loculus-registration-ingress + annotations: + traefik.ingress.kubernetes.io/router.middlewares: "{{ join "," $middlewareList }}" +spec: + {{- if $.Values.ingressClassName }} + ingressClassName: "{{ $.Values.ingressClassName }}" + {{- end }} + rules: + - host: "{{ $registerHost }}" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: loculus-registration-service + port: + number: 8090 + tls: + - hosts: + - "{{ $registerHost }}" +{{- end }} +--- {{- if and .Values.s3.enabled .Values.runDevelopmentS3 }} apiVersion: networking.k8s.io/v1 kind: Ingress @@ -153,6 +213,9 @@ metadata: annotations: traefik.ingress.kubernetes.io/router.middlewares: "{{ join "," $middlewareList }}" spec: + {{- if $.Values.ingressClassName }} + ingressClassName: "{{ $.Values.ingressClassName }}" + {{- end }} rules: - host: "{{ $minioHost }}" http: @@ -168,4 +231,3 @@ spec: - hosts: - "{{ $minioHost }}" {{- end }} -{{- end }} diff --git a/kubernetes/loculus/templates/keycloak-config-map.yaml b/kubernetes/loculus/templates/keycloak-config-map.yaml deleted file mode 100644 index fc7c6bf14b..0000000000 --- a/kubernetes/loculus/templates/keycloak-config-map.yaml +++ /dev/null @@ -1,377 +0,0 @@ -{{- $keycloakHost := (printf "authentication%s%s" $.Values.subdomainSeparator $.Values.host) }} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: keycloak-config -data: - keycloak-config.json: | - { - "realm": "loculus", - "enabled": true, - "verifyEmail": {{$.Values.auth.verifyEmail}}, - "resetPasswordAllowed": {{$.Values.auth.resetPasswordAllowed}}, - {{- if $.Values.auth.verifyEmail }} - "smtpServer": { - "host": "{{$.Values.auth.smtp.host}}", - "port": "{{$.Values.auth.smtp.port}}", - "from": "{{$.Values.auth.smtp.from}}", - "fromDisplayName": "{{$.Values.name}}", - "replyTo": "{{$.Values.auth.smtp.replyTo}}", - "replyToDisplayName": "{{$.Values.name}}", - "envelopeFrom": "{{$.Values.auth.smtp.envelopeFrom}}", - "ssl": "false", - "starttls": "true", - "auth": "true", - "user": "{{$.Values.auth.smtp.user}}", - "password": "[[smtpPassword]]" - }, - {{- end }} - "registrationAllowed": {{ $.Values.auth.registrationAllowed }}, - "accessTokenLifespan": 36000, - "ssoSessionIdleTimeout": 36000, - "actionTokenGeneratedByUserLifespan": 1800, - "users": [ - {{ if $.Values.createTestAccounts }} - {{- $browsers := list "firefox" "webkit" "chromium"}} - {{- range $_, $browser := $browsers }} - {{- range $index, $_ := until 20}} - { - "username": "testuser_{{$index}}_{{$browser}}", - "enabled": true, - "email": "testuser_{{$index}}_{{$browser}}@void.o", - "emailVerified": true, - "firstName": "{{$index}}_{{$browser}}", - "lastName": "TestUser", - "credentials": [ - { - "type": "password", - "value": "testuser_{{$index}}_{{$browser}}" - } - ], - "realmRoles": [ - "user", - "offline_access" - ], - "attributes": { - "university": "University of Test" - }, - "clientRoles": { - "account": [ - "manage-account" - ] - } - }, - {{ end }} - {{ end }} - { - "username": "testuser", - "enabled": true, - "email": "testuser@void.o", - "emailVerified" : true, - "firstName": "Test", - "lastName": "User", - "credentials": [ - { - "type": "password", - "value": "testuser" - } - ], - "realmRoles": [ - "user", - "offline_access" - ], - "attributes": { - "university": "University of Test" - }, - "clientRoles": { - "account": [ - "manage-account" - ] - } - }, - { - "username": "superuser", - "enabled": true, - "email": "superuser@void.o", - "emailVerified" : true, - "firstName": "Dummy", - "lastName": "SuperUser", - "credentials": [ - { - "type": "password", - "value": "superuser" - } - ], - "realmRoles": [ - "super_user", - "offline_access" - ], - "attributes": { - "university": "University of Test" - }, - "clientRoles": { - "account": [ - "manage-account" - ] - } - }, - {{ end }} - { - "username": "insdc_ingest_user", - "enabled": true, - "email": "insdc_ingest_user@void.o", - "emailVerified" : true, - "firstName": "INSDC Ingest", - "lastName": "User", - "credentials": [ - { - "type": "password", - "value": "[[insdcIngestUserPassword]]" - } - ], - "realmRoles": [ - "user", - "offline_access" - ], - "attributes": { - "university": "University of Test" - }, - "clientRoles": { - "account": [ - "manage-account" - ] - } - }, - { - "username": "preprocessing_pipeline", - "enabled": true, - "email": "preprocessing_pipeline@void.o", - "emailVerified" : true, - "firstName": "Dummy", - "lastName": "Preprocessing", - "credentials": [ - { - "type": "password", - "value": "[[preprocessingPipelinePassword]]" - } - ], - "realmRoles": [ - "preprocessing_pipeline", - "offline_access" - ], - "attributes": { - "university": "University of Test" - }, - "clientRoles": { - "account": [ - "manage-account" - ] - } - }, - { - "username": "external_metadata_updater", - "enabled": true, - "email": "external_metadata_updater@void.o", - "emailVerified" : true, - "firstName": "Dummy", - "lastName": "INSDC", - "credentials": [ - { - "type": "password", - "value": "[[externalMetadataUpdaterPassword]]" - } - ], - "realmRoles": [ - "external_metadata_updater", - "get_released_data", - "offline_access" - ], - "attributes": { - "university": "University of Test" - }, - "clientRoles": { - "account": [ - "manage-account" - ] - } - }, - { - "username": "backend", - "enabled": true, - "email": "nothing@void.o", - "emailVerified": true, - "firstName": "Backend", - "lastName": "Technical-User", - "attributes": { - "university": "University of Test" - }, - "credentials": [ - { - "type": "password", - "value": "[[backendUserPassword]]" - } - ], - "clientRoles": { - "realm-management": [ - "view-users" - ], - "account": [ - "manage-account" - ] - } - } - ], - "roles": { - "realm": [ - { - "name": "user", - "description": "User privileges" - }, - { - "name": "admin", - "description": "Administrator privileges" - }, - { - "name": "preprocessing_pipeline", - "description": "Preprocessing pipeline privileges" - }, - { - "name": "external_metadata_updater", - "description": "External Submitter privileges" - }, - { - "name": "get_released_data", - "description": "Privileges for getting released data" - }, - { - "name": "super_user", - "description": "Privileges for curators to modify sequence entries of any user" - } - ] - }, - "clients": [ - { - "clientId": "backend-client", - "enabled": true, - "publicClient": true, - "directAccessGrantsEnabled": true, - "redirectUris": [ - "https://{{$.Values.host}}/*", - "http://{{$.Values.host}}/*", - "http://localhost:3000/*" - ] - }, - { - "clientId" : "account-console2", - "name" : "${client_account-console}", - "description" : "", - "rootUrl" : "${authBaseUrl}", - "adminUrl" : "", - "baseUrl" : "/realms/loculus/account/", - "surrogateAuthRequired" : false, - "enabled" : true, - "alwaysDisplayInConsole" : false, - "clientAuthenticatorType" : "client-secret", - "redirectUris" : [ "/realms/loculus/account/*" ], - "webOrigins" : [ "+" ], - "notBefore" : 0, - "bearerOnly" : false, - "consentRequired" : false, - "standardFlowEnabled" : true, - "implicitFlowEnabled" : false, - "directAccessGrantsEnabled" : false, - "serviceAccountsEnabled" : false, - "publicClient" : true, - "frontchannelLogout" : false, - "protocol" : "openid-connect", - "attributes" : { - "oidc.ciba.grant.enabled" : "false", - "backchannel.logout.session.required" : "true", - "post.logout.redirect.uris" : "+", - "oauth2.device.authorization.grant.enabled" : "false", - "display.on.consent.screen" : "false", - "pkce.code.challenge.method" : "S256", - "backchannel.logout.revoke.offline.tokens" : "false" - }, - "authenticationFlowBindingOverrides" : { }, - "fullScopeAllowed" : false, - "nodeReRegistrationTimeout" : 0, - "protocolMappers" : [ - { - "name" : "audience resolve", - "protocol" : "openid-connect", - "protocolMapper" : "oidc-audience-resolve-mapper", - "consentRequired" : false, - "config" : { } - } - ], - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], - "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] - } - ], - "attributes": { - "frontendUrl": "{{ include "loculus.keycloakUrl" . }}", - "userProfileEnabled" : "true" - }, - "components": { - "org.keycloak.userprofile.UserProfileProvider" : [ - { - "providerId" : "declarative-user-profile", - "subComponents" : { }, - "config" : { - "kc.user.profile.config" : [ "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"university\",\"displayName\":\"University / Organisation\",\"validations\":{},\"annotations\":{},\"required\":{\"roles\":[\"admin\",\"user\"]},\"permissions\":{\"view\":[],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"orcid\",\"displayName\":\"\",\"permissions\":{\"edit\":[\"admin\"],\"view\":[\"admin\",\"user\"]},\"annotations\":{},\"validations\":{}}],\"groups\":[]}" ] - } - } - ] - }, - "loginTheme": "loculus", - "emailTheme": "loculus", - "identityProviders" : [ - {{- range $key, $value := .Values.auth.identityProviders }} - {{- if eq $key "orcid" }} - { - "alias" : "orcid", - "providerId" : "orcid", - "enabled" : true, - "updateProfileFirstLoginMode" : "on", - "trustEmail" : false, - "storeToken" : false, - "addReadTokenRoleOnCreate" : false, - "authenticateByDefault" : false, - "linkOnly" : false, - "firstBrokerLoginFlowAlias" : "first broker login", - "config" : { - "clientSecret" : "[[orcidSecret]]", - "clientId" : "{{ $value.clientId }}" - } - } - {{- end }} - {{- end }} - ], - "identityProviderMappers" : [ - {{- range $key, $_ := .Values.auth.identityProviders }} - {{- if eq $key "orcid" }} - { - "name" : "username mapper", - "identityProviderAlias" : "orcid", - "identityProviderMapper" : "hardcoded-attribute-idp-mapper", - "config" : { - "syncMode" : "IMPORT", - "attribute" : "username" - } - }, - { - "name" : "orcid", - "identityProviderAlias" : "orcid", - "identityProviderMapper" : "orcid-user-attribute-mapper", - "config" : { - "syncMode" : "INHERIT", - "jsonField" : "orcid-identifier", - "userAttribute" : "orcid.path" - } - } - {{- end }} - {{- end }} - ] - } diff --git a/kubernetes/loculus/templates/keycloak-database-service.yaml b/kubernetes/loculus/templates/keycloak-database-service.yaml deleted file mode 100644 index 0195ccb03c..0000000000 --- a/kubernetes/loculus/templates/keycloak-database-service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -{{- if .Values.runDevelopmentKeycloakDatabase }} -apiVersion: v1 -kind: Service -metadata: - name: loculus-keycloak-database-service - annotations: - argocd.argoproj.io/sync-wave: "-5" -spec: - type: ClusterIP - selector: - app: loculus - component: keycloak-database - ports: - - port: 5432 -{{- end }} diff --git a/kubernetes/loculus/templates/keycloak-database-standin.yaml b/kubernetes/loculus/templates/keycloak-database-standin.yaml deleted file mode 100644 index 3d6c6c461e..0000000000 --- a/kubernetes/loculus/templates/keycloak-database-standin.yaml +++ /dev/null @@ -1,50 +0,0 @@ -{{- $dockerTag := include "loculus.dockerTag" .Values }} -{{- if .Values.runDevelopmentKeycloakDatabase }} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: loculus-keycloak-database - annotations: - argocd.argoproj.io/sync-options: Replace=true - argocd.argoproj.io/sync-wave: "-5" -spec: - replicas: 1 - selector: - matchLabels: - app: loculus - component: keycloak-database - strategy: - type: Recreate - template: - metadata: - annotations: - timestamp: {{ now | quote }} - labels: - app: loculus - component: keycloak-database - spec: - containers: - - name: loculus-keycloak-database - image: postgres:15.12 - resources: - requests: - memory: "30Mi" - cpu: 10m - limits: - memory: "100Mi" - ports: - - containerPort: 5432 - env: - - name: POSTGRES_USER - value: "postgres" - - name: POSTGRES_PASSWORD - value: "unsecure" - - name: POSTGRES_DB - value: "keycloak" - - name: POSTGRES_HOST_AUTH_METHOD - value: "trust" - {{ if not .Values.developmentDatabasePersistence }} - - name: LOCULUS_VERSION - value: {{ $dockerTag }} - {{- end }} -{{- end }} diff --git a/kubernetes/loculus/templates/keycloak-deployment.yaml b/kubernetes/loculus/templates/keycloak-deployment.yaml deleted file mode 100644 index 78bd59233f..0000000000 --- a/kubernetes/loculus/templates/keycloak-deployment.yaml +++ /dev/null @@ -1,134 +0,0 @@ ---- -{{- $dockerTag := include "loculus.dockerTag" .Values }} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: loculus-keycloak - annotations: - argocd.argoproj.io/sync-options: Replace=true -spec: - replicas: 1 - selector: - matchLabels: - app: loculus - component: keycloak - template: - metadata: - labels: - app: loculus - component: keycloak - spec: - {{- include "possiblePriorityClassName" . | nindent 6 }} - initContainers: -{{- include "loculus.configProcessor" (dict "name" "keycloak-config" "dockerTag" $dockerTag "imagePullPolicy" .Values.imagePullPolicy) | nindent 8 }} - - name: keycloak-theme-prep - resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 256Mi - image: "ghcr.io/loculus-project/keycloakify:{{ $dockerTag }}" - volumeMounts: - - name: theme-volume - mountPath: /destination - containers: - - name: keycloak - # TODO #1221 - image: quay.io/keycloak/keycloak:23.0 - {{- include "loculus.resources" (list "keycloak" $.Values) | nindent 10 }} - env: - - name: REGISTRATION_TERMS_MESSAGE - value: {{ $.Values.registrationTermsMessage }} - - name: PROJECT_NAME - value: {{ $.Values.name }} - - name: KC_DB - value: postgres - - name: KC_DB_URL_HOST - valueFrom: - secretKeyRef: - name: keycloak-database - key: addr - - name: KC_DB_URL_PORT - valueFrom: - secretKeyRef: - name: keycloak-database - key: port - - name: KC_DB_URL_DATABASE - valueFrom: - secretKeyRef: - name: keycloak-database - key: database - - name: KC_DB_USERNAME - valueFrom: - secretKeyRef: - name: keycloak-database - key: username - - name: KC_DB_PASSWORD - valueFrom: - secretKeyRef: - name: keycloak-database - key: password - - name: KC_BOOTSTRAP_ADMIN_USERNAME # TODO: delete after upgrading keycloak (#3736 ) - value: "admin" - - name: KC_BOOTSTRAP_ADMIN_PASSWORD # TODO: delete after upgrading keycloak (#3736 ) - valueFrom: - secretKeyRef: - name: keycloak-admin - key: initialAdminPassword - - name: KEYCLOAK_ADMIN - value: "admin" - - name: KEYCLOAK_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: keycloak-admin - key: initialAdminPassword - - name: KC_PROXY - value: "edge" - - name: PROXY_ADDRESS_FORWARDING - value: "true" - - name: KC_HEALTH_ENABLED - value: "true" - - name: KC_HOSTNAME_URL - value: "{{ include "loculus.keycloakUrl" . }}" - - name: KC_HOSTNAME_ADMIN_URL - value: "{{ include "loculus.keycloakUrl" . }}" - - name: KC_FEATURES - value: "declarative-user-profile" - # see https://github.com/keycloak/keycloak/blob/77b58275ca06d1cbe430c51db74479a7e1b409b5/quarkus/dist/src/main/content/bin/kc.sh#L95-L150 - - name: KC_RUN_IN_CONTAINER - value: "true" - {{- if .Values.runDevelopmentKeycloakDatabase }} - - name: LOCULUS_VERSION - value: {{ $dockerTag }} - {{- end }} - args: - - "start" - - "--import-realm" - - "--cache=local" - ports: - - containerPort: 8080 - volumeMounts: - - name: keycloak-config-processed - mountPath: /opt/keycloak/data/import/ - - name: theme-volume - mountPath: /opt/keycloak/providers/ - startupProbe: - httpGet: - path: /health/ready - port: 8080 - timeoutSeconds: 3 - failureThreshold: 150 - periodSeconds: 5 - livenessProbe: - httpGet: - path: /health/ready - port: 8080 - timeoutSeconds: 3 - periodSeconds: 10 - failureThreshold: 2 - volumes: -{{ include "loculus.configVolume" (dict "name" "keycloak-config") | nindent 8 }} - - name: theme-volume - emptyDir: {} diff --git a/kubernetes/loculus/templates/lapis-ingress.yaml b/kubernetes/loculus/templates/lapis-ingress.yaml index 928ade4ed4..c318c2634b 100644 --- a/kubernetes/loculus/templates/lapis-ingress.yaml +++ b/kubernetes/loculus/templates/lapis-ingress.yaml @@ -30,6 +30,9 @@ metadata: annotations: traefik.ingress.kubernetes.io/router.middlewares: "{{ $.Release.Namespace }}-cors-all-origins@kubernetescrd,{{- $first := true }}{{- range $key := $organismKeys }}{{ if $first }}{{ $first = false }}{{ else }},{{ end }}{{ $.Release.Namespace }}-strip-{{ $key }}-prefix@kubernetescrd{{- end }}" spec: + {{- if $.Values.ingressClassName }} + ingressClassName: "{{ $.Values.ingressClassName }}" + {{- end }} rules: - host: {{ if eq $.Values.environment "server" }}{{ $lapisHost }}{{ end }} http: @@ -87,6 +90,9 @@ metadata: # High priority to ensure this rule is evaluated first traefik.ingress.kubernetes.io/router.priority: "500" spec: + {{- if $.Values.ingressClassName }} + ingressClassName: "{{ $.Values.ingressClassName }}" + {{- end }} rules: - host: {{ if eq $.Values.environment "server" }}{{ $lapisHost }}{{ end }} http: diff --git a/kubernetes/loculus/templates/lldap-bootstrap-configmap.yaml b/kubernetes/loculus/templates/lldap-bootstrap-configmap.yaml new file mode 100644 index 0000000000..620ad0336b --- /dev/null +++ b/kubernetes/loculus/templates/lldap-bootstrap-configmap.yaml @@ -0,0 +1,69 @@ +{{- if .Values.auth.bundledLdap.enabled }} +{{- /* Build the lists; secret placeholders ([[xxx]]) are substituted later + by loculus.configProcessor. lldap's own /app/bootstrap.sh script + reads one JSON file per user / group from /bootstrap/user-configs and + /bootstrap/group-configs, then calls /app/lldap_set_password to set + passwords via OPAQUE — which is the only supported path. */}} +{{- $users := list }} +{{- if .Values.createTestAccounts }} + {{- range $_, $browser := list "firefox" "webkit" "chromium" }} + {{- range $i, $_ := until 20 }} + {{- $users = append $users (dict + "id" (printf "testuser_%d_%s" $i $browser) + "email" (printf "testuser_%d_%s@void.o" $i $browser) + "firstName" (printf "%d_%s" $i $browser) + "lastName" "TestUser" + "displayName" (printf "%d_%s TestUser" $i $browser) + "password" (printf "testuser_%d_%s" $i $browser) + "groups" (list "user")) }} + {{- end }} + {{- end }} + {{- $users = append $users (dict + "id" "testuser" "email" "testuser@void.o" + "firstName" "Test" "lastName" "User" "displayName" "Test User" + "password" "testuser" "groups" (list "user")) }} + {{- $users = append $users (dict + "id" "superuser" "email" "superuser@void.o" + "firstName" "Dummy" "lastName" "SuperUser" "displayName" "Dummy SuperUser" + "password" "superuser" "groups" (list "super_user" "user")) }} + {{- $users = append $users (dict + "id" "playwright-readonly-setup-user" + "email" "playwright-readonly-setup-user@example.com" + "firstName" "Playwright" "lastName" "Setup" + "displayName" "Playwright Setup" + "password" "a-very-secure-password-for-testing" + "groups" (list "user")) }} +{{- end }} +{{- $users = append $users (dict + "id" "insdc_ingest_user" "email" "insdc_ingest_user@void.o" + "firstName" "INSDC Ingest" "lastName" "User" "displayName" "INSDC Ingest User" + "password" "[[insdcIngestUserPassword]]" "groups" (list "user")) }} +{{- $users = append $users (dict + "id" "preprocessing_pipeline" "email" "preprocessing_pipeline@void.o" + "firstName" "Dummy" "lastName" "Preprocessing" "displayName" "Dummy Preprocessing" + "password" "[[preprocessingPipelinePassword]]" "groups" (list "preprocessing_pipeline")) }} +{{- $users = append $users (dict + "id" "external_metadata_updater" "email" "external_metadata_updater@void.o" + "firstName" "Dummy" "lastName" "INSDC" "displayName" "Dummy INSDC" + "password" "[[externalMetadataUpdaterPassword]]" + "groups" (list "external_metadata_updater" "get_released_data")) }} +{{- $users = append $users (dict + "id" "backend" "email" "nothing@void.o" + "firstName" "Backend" "lastName" "Technical-User" "displayName" "Backend Technical-User" + "password" "[[backendUserPassword]]" "groups" (list "user")) }} +{{- $groups := list "user" "admin" "preprocessing_pipeline" "external_metadata_updater" "get_released_data" "super_user" }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: lldap-bootstrap +data: +{{- range $g := $groups }} + group-{{ $g }}.json: |- + {"name": "{{ $g }}"} +{{- end }} +{{- range $u := $users }} + user-{{ $u.id }}.json: |- +{{ $u | toJson | indent 4 }} +{{- end }} +{{- end }} diff --git a/kubernetes/loculus/templates/lldap-deployment.yaml b/kubernetes/loculus/templates/lldap-deployment.yaml new file mode 100644 index 0000000000..058e9952c1 --- /dev/null +++ b/kubernetes/loculus/templates/lldap-deployment.yaml @@ -0,0 +1,135 @@ +{{- if .Values.auth.bundledLdap.enabled }} +{{- $dockerTag := include "loculus.dockerTag" .Values }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: loculus-lldap + annotations: + argocd.argoproj.io/sync-options: Replace=true +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: loculus + component: lldap + template: + metadata: + labels: + app: loculus + component: lldap + spec: + {{- include "possiblePriorityClassName" . | nindent 6 }} + initContainers: +{{- include "loculus.configProcessor" (dict "name" "lldap-bootstrap" "dockerTag" $dockerTag "imagePullPolicy" .Values.imagePullPolicy) | nindent 8 }} + containers: + - name: lldap + image: "lldap/lldap:v0.6.1" + {{- include "loculus.resources" (list "lldap" $.Values) | nindent 10 }} + env: + - name: LLDAP_JWT_SECRET + valueFrom: + secretKeyRef: + name: lldap-secrets + key: jwtSecret + - name: LLDAP_KEY_SEED + valueFrom: + secretKeyRef: + name: lldap-secrets + key: keySeed + - name: LLDAP_LDAP_USER_PASS + valueFrom: + secretKeyRef: + name: lldap-secrets + key: adminPassword + - name: LLDAP_LDAP_BASE_DN + value: "dc=loculus,dc=org" + - name: LLDAP_LDAP_USER_DN + value: "admin" + - name: LLDAP_LDAP_USER_EMAIL + value: "admin@loculus.org" + - name: LLDAP_HTTP_URL + value: "http://loculus-lldap-service:17170" + - name: LLDAP_HTTP_HOST + value: "0.0.0.0" + # Bootstrap script env (consumed by /app/bootstrap.sh) + - name: LLDAP_URL + value: "http://127.0.0.1:17170" + - name: LLDAP_ADMIN_USERNAME + value: "admin" + - name: LLDAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: lldap-secrets + key: adminPassword + - name: USER_CONFIGS_DIR + value: "/bootstrap" + - name: GROUP_CONFIGS_DIR + value: "/bootstrap" + ports: + - name: ldap + containerPort: 3890 + - name: http + containerPort: 17170 + volumeMounts: + - name: data + mountPath: /data + - name: lldap-bootstrap-processed + mountPath: /bootstrap-input + readOnly: true + - name: bootstrap-work + mountPath: /bootstrap + startupProbe: + httpGet: + path: /health + port: 17170 + failureThreshold: 30 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: 17170 + periodSeconds: 30 + lifecycle: + postStart: + exec: + command: + - "/bin/sh" + - "-c" + - | + set -e + # Wait for lldap to be ready then run the official + # bootstrap script which handles OPAQUE password setting. + for i in $(seq 1 60); do + if wget -qO- http://127.0.0.1:17170/health >/dev/null 2>&1; then + break + fi + sleep 2 + done + # Filter processed user/group json files into the right shape + # for /app/bootstrap.sh. + rm -rf /bootstrap/users /bootstrap/groups + mkdir -p /bootstrap/users /bootstrap/groups + for f in /bootstrap-input/user-*.json; do + [ -e "$f" ] || continue + cp "$f" /bootstrap/users/$(basename "$f") + done + for f in /bootstrap-input/group-*.json; do + [ -e "$f" ] || continue + cp "$f" /bootstrap/groups/$(basename "$f") + done + USER_CONFIGS_DIR=/bootstrap/users GROUP_CONFIGS_DIR=/bootstrap/groups /app/bootstrap.sh > /tmp/bootstrap.log 2>&1 || true + volumes: + - name: data + {{- if .Values.developmentDatabasePersistence }} + persistentVolumeClaim: + claimName: lldap-data + {{- else }} + emptyDir: {} + {{- end }} +{{ include "loculus.configVolume" (dict "name" "lldap-bootstrap") | nindent 8 }} + - name: bootstrap-work + emptyDir: {} +{{- end }} diff --git a/kubernetes/loculus/templates/lldap-service.yaml b/kubernetes/loculus/templates/lldap-service.yaml new file mode 100644 index 0000000000..455ca21b13 --- /dev/null +++ b/kubernetes/loculus/templates/lldap-service.yaml @@ -0,0 +1,20 @@ +{{- if .Values.auth.bundledLdap.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: loculus-lldap-service +spec: + type: ClusterIP + selector: + app: loculus + component: lldap + ports: + - name: ldap + port: 3890 + targetPort: 3890 + protocol: TCP + - name: http + port: 17170 + targetPort: 17170 + protocol: TCP +{{- end }} diff --git a/kubernetes/loculus/templates/loculus-backend.yaml b/kubernetes/loculus/templates/loculus-backend.yaml index 39312643b2..81652bd7e5 100644 --- a/kubernetes/loculus/templates/loculus-backend.yaml +++ b/kubernetes/loculus/templates/loculus-backend.yaml @@ -58,15 +58,19 @@ spec: - "--crossref.organization=$(CROSSREF_ORGANIZATION)" - "--crossref.host-url=$(CROSSREF_HOST_URL)" {{- end }} - - "--keycloak.password=$(BACKEND_KEYCLOAK_PASSWORD)" - - "--keycloak.realm=loculus" - - "--keycloak.client=backend-client" - - "--keycloak.url=http://loculus-keycloak-service:8083" - - "--keycloak.user=backend" + - "--loculus.ldap.host={{ .Values.auth.ldap.host }}" + - "--loculus.ldap.port={{ .Values.auth.ldap.port }}" + - "--loculus.ldap.base-dn={{ .Values.auth.ldap.baseDn }}" + - "--loculus.ldap.user-base-dn={{ .Values.auth.ldap.userBaseDn }}" + - "--loculus.ldap.group-base-dn={{ .Values.auth.ldap.groupBaseDn }}" + - "--loculus.ldap.user-filter={{ .Values.auth.ldap.userFilter }}" + - "--loculus.ldap.bind-dn={{ .Values.auth.ldap.bindDn }}" + - "--loculus.ldap.bind-password=$(LDAP_BIND_PASSWORD)" - "--spring.datasource.password=$(DB_PASSWORD)" - "--spring.datasource.url=$(DB_URL)" - "--spring.datasource.username=$(DB_USERNAME)" - - "--spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://loculus-keycloak-service:8083/realms/loculus/protocol/openid-connect/certs" + - "--spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://loculus-authelia-service:9091/jwks.json" + - "--spring.security.oauth2.resourceserver.jwt.issuer-uri={{ include "loculus.autheliaUrl" . }}" - "--loculus.cleanup.task.reset-stale-in-processing-after-seconds={{- .Values.preprocessingTimeout | default 120 }}" - "--loculus.pipeline-version-upgrade-check.interval-seconds={{- .Values.pipelineVersionUpgradeCheckIntervalSeconds | default 10 }}" - "--loculus.s3.enabled=$(S3_ENABLED)" @@ -108,7 +112,27 @@ spec: - name: CROSSREF_HOST_URL value: {{$.Values.seqSets.crossRef.hostUrl | quote }} {{- end }} - - name: BACKEND_KEYCLOAK_PASSWORD + - name: LDAP_BIND_PASSWORD + valueFrom: + secretKeyRef: + name: lldap-secrets + key: adminPassword + - name: LOCULUS_SERVICE_TOKENS_PREPROCESSING_PIPELINE + valueFrom: + secretKeyRef: + name: service-accounts + key: preprocessingPipelinePassword + - name: LOCULUS_SERVICE_TOKENS_EXTERNAL_METADATA_UPDATER + valueFrom: + secretKeyRef: + name: service-accounts + key: externalMetadataUpdaterPassword + - name: LOCULUS_SERVICE_TOKENS_INSDC_INGEST_USER + valueFrom: + secretKeyRef: + name: service-accounts + key: insdcIngestUserPassword + - name: LOCULUS_SERVICE_TOKENS_BACKEND valueFrom: secretKeyRef: name: service-accounts @@ -155,4 +179,4 @@ spec: mountPath: /config volumes: {{ include "loculus.configVolume" (dict "name" "loculus-backend-config") | nindent 8 }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/kubernetes/loculus/templates/loculus-preprocessing-deployment.yaml b/kubernetes/loculus/templates/loculus-preprocessing-deployment.yaml index fd728ba58e..43b28d918d 100644 --- a/kubernetes/loculus/templates/loculus-preprocessing-deployment.yaml +++ b/kubernetes/loculus/templates/loculus-preprocessing-deployment.yaml @@ -4,7 +4,7 @@ "http://loculus-backend-service:8079" }} {{- $testconfig := .Values.testconfig | default false }} -{{- $keycloakHost := $testconfig | ternary (printf "http://%s:8083" $.Values.localHost) "http://loculus-keycloak-service:8083" }} +{{- $keycloakHost := $testconfig | ternary (printf "http://%s:9091" $.Values.localHost) "http://loculus-authelia-service:9091" }} {{- if not .Values.disablePreprocessing }} {{- range $_, $item := (include "loculus.enabledOrganisms" . | fromJson).organisms }} {{- $organism := $item.key }} diff --git a/kubernetes/loculus/templates/loculus-website-config.yaml b/kubernetes/loculus/templates/loculus-website-config.yaml index 3c5df57465..136f1854a6 100644 --- a/kubernetes/loculus/templates/loculus-website-config.yaml +++ b/kubernetes/loculus/templates/loculus-website-config.yaml @@ -20,11 +20,13 @@ data: "backendUrl": "http://loculus-backend-service:8079", {{- end }} "lapisUrls": {{- include "loculus.generateInternalLapisUrls" . | fromYaml | toJson }}, - "keycloakUrl": "{{ if not .Values.disableWebsite -}}http://loculus-keycloak-service:8083{{ else -}}http://{{ $.Values.localHost }}:8083{{ end }}" + "autheliaUrl": "{{ if not .Values.disableWebsite -}}{{ include "loculus.autheliaUrlInternal" . }}{{ else -}}http://{{ $.Values.localHost }}:9091{{ end }}", + "autheliaPublicUrl": "{{ include "loculus.autheliaUrl" . }}", + "registrationUrl": "{{ include "loculus.registrationUrl" . }}", + "oidcClientSecret": "[[backendClientSecretPlain]]" {{- end }} }, "public": { {{- template "loculus.publicRuntimeConfig" . -}} - }, - "backendKeycloakClientSecret" : "[[backendKeycloakClientSecret]]" + } } diff --git a/kubernetes/loculus/templates/registration-service-deployment.yaml b/kubernetes/loculus/templates/registration-service-deployment.yaml new file mode 100644 index 0000000000..aed169d217 --- /dev/null +++ b/kubernetes/loculus/templates/registration-service-deployment.yaml @@ -0,0 +1,57 @@ +{{- if .Values.auth.bundledLdap.enabled }} +{{- $dockerTag := include "loculus.dockerTag" .Values }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: loculus-registration-service + annotations: + argocd.argoproj.io/sync-options: Replace=true +spec: + replicas: 1 + selector: + matchLabels: + app: loculus + component: registration-service + template: + metadata: + labels: + app: loculus + component: registration-service + spec: + {{- include "possiblePriorityClassName" . | nindent 6 }} + containers: + - name: registration-service + image: "{{ .Values.images.registrationService.repository }}:{{ $dockerTag }}" + imagePullPolicy: {{ .Values.images.registrationService.pullPolicy | default .Values.imagePullPolicy }} + {{- include "loculus.resources" (list "registration-service" $.Values) | nindent 10 }} + env: + - name: LLDAP_URL + value: "http://loculus-lldap-service:17170" + - name: LLDAP_ADMIN_USERNAME + value: "admin" + - name: LLDAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: lldap-secrets + key: adminPassword + - name: BASE_URL + value: "{{ include "loculus.registrationUrl" . }}" + - name: LOGIN_URL + value: "{{ include "loculus.autheliaUrl" . }}" + - name: TERMS_MESSAGE + value: {{ $.Values.registrationTermsMessage | quote }} + ports: + - containerPort: 8090 + startupProbe: + httpGet: + path: /health + port: 8090 + failureThreshold: 30 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: 8090 + periodSeconds: 30 +{{- end }} diff --git a/kubernetes/loculus/templates/registration-service-service.yaml b/kubernetes/loculus/templates/registration-service-service.yaml new file mode 100644 index 0000000000..19d7c28473 --- /dev/null +++ b/kubernetes/loculus/templates/registration-service-service.yaml @@ -0,0 +1,16 @@ +{{- if .Values.auth.bundledLdap.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: loculus-registration-service +spec: + type: ClusterIP + selector: + app: loculus + component: registration-service + ports: + - port: 8090 + targetPort: 8090 + protocol: TCP + name: http +{{- end }} diff --git a/kubernetes/loculus/templates/silo-deployment.yaml b/kubernetes/loculus/templates/silo-deployment.yaml index a66a5199d5..4e550dacd0 100644 --- a/kubernetes/loculus/templates/silo-deployment.yaml +++ b/kubernetes/loculus/templates/silo-deployment.yaml @@ -1,5 +1,5 @@ {{- $dockerTag := include "loculus.dockerTag" .Values }} -{{- $keycloakTokenUrl := "http://loculus-keycloak-service:8083/realms/loculus/protocol/openid-connect/token" }} +{{- $keycloakTokenUrl := "http://loculus-authelia-service:9091/api/oidc/token" }} {{- range $_, $item := (include "loculus.enabledOrganisms" . | fromJson).organisms }} {{- $key := $item.key }} diff --git a/kubernetes/loculus/values.schema.json b/kubernetes/loculus/values.schema.json index 5e496e0114..7ac7cfd9f3 100644 --- a/kubernetes/loculus/values.schema.json +++ b/kubernetes/loculus/values.schema.json @@ -1463,6 +1463,12 @@ "default": true, "description": "If true, allows users to register new accounts in Keycloak." }, + "relaxOidcTokenRateLimit": { + "groups": ["auth"], + "type": "boolean", + "default": false, + "description": "If true, raises Authelia's OIDC token endpoint rate limits for local e2e/dev auth flows." + }, "identityProviders": { "type": "object", "additionalProperties": false, @@ -1479,6 +1485,32 @@ } } } + }, + "bundledLdap": { + "groups": ["auth"], + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "If true, deploys lldap and the registration service in-cluster." + } + } + }, + "ldap": { + "groups": ["auth"], + "type": "object", + "additionalProperties": false, + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer" }, + "baseDn": { "type": "string" }, + "userBaseDn": { "type": "string" }, + "groupBaseDn": { "type": "string" }, + "userFilter": { "type": "string" }, + "bindDn": { "type": "string" } + } } } }, @@ -1500,12 +1532,6 @@ "default": true, "description": "If true, runs a development database within the cluster." }, - "runDevelopmentKeycloakDatabase": { - "groups": ["db"], - "type": "boolean", - "default": true, - "description": "If true, runs a development Keycloak database within the cluster." - }, "developmentDatabasePersistence": { "groups": ["db"], "type": "boolean", @@ -1613,6 +1639,12 @@ "default": 2, "description": "Traefik major version for CRD API group. Use 2 for traefik.containo.us/v1alpha1 (Traefik v2) or 3 for traefik.io/v1alpha1 (Traefik v3)." }, + "ingressClassName": { + "groups": ["general"], + "type": "string", + "default": "traefik", + "description": "Ingress class name used for Kubernetes Ingress resources." + }, "ingestLimitSeconds": { "type": "integer", "default": 1800, @@ -1810,7 +1842,8 @@ "lapisSilo": { "$ref": "#/definitions/imageSpec" }, "loculusSilo": { "$ref": "#/definitions/imageSpec" }, "website": { "$ref": "#/definitions/imageSpec" }, - "backend": { "$ref": "#/definitions/imageSpec" } + "backend": { "$ref": "#/definitions/imageSpec" }, + "registrationService": { "$ref": "#/definitions/imageSpec" } }, "additionalProperties": false }, @@ -1899,7 +1932,9 @@ "ena-submission": { "$ref": "#/definitions/resourceSpec" }, "ena-submission-list-cronjob": { "$ref": "#/definitions/resourceSpec" }, "ingest": { "$ref": "#/definitions/resourceSpec" }, - "keycloak": { "$ref": "#/definitions/resourceSpec" }, + "authelia": { "$ref": "#/definitions/resourceSpec" }, + "lldap": { "$ref": "#/definitions/resourceSpec" }, + "registration-service": { "$ref": "#/definitions/resourceSpec" }, "silo": { "$ref": "#/definitions/resourceSpec" }, "lapis": { "$ref": "#/definitions/resourceSpec" }, "silo-importer": { "$ref": "#/definitions/resourceSpec" }, @@ -1917,7 +1952,9 @@ "$ref": "#/definitions/resourceSpec" }, "ingest": { "$ref": "#/definitions/resourceSpec" }, - "keycloak": { "$ref": "#/definitions/resourceSpec" }, + "authelia": { "$ref": "#/definitions/resourceSpec" }, + "lldap": { "$ref": "#/definitions/resourceSpec" }, + "registration-service": { "$ref": "#/definitions/resourceSpec" }, "silo": { "$ref": "#/definitions/resourceSpec" }, "lapis": { "$ref": "#/definitions/resourceSpec" }, "silo-importer": { "$ref": "#/definitions/resourceSpec" }, diff --git a/kubernetes/loculus/values.yaml b/kubernetes/loculus/values.yaml index 0a74e2eecd..edb000cbd5 100644 --- a/kubernetes/loculus/values.yaml +++ b/kubernetes/loculus/values.yaml @@ -2645,6 +2645,23 @@ auth: verifyEmail: false resetPasswordAllowed: true registrationAllowed: true + relaxOidcTokenRateLimit: false + # Bundled LDAP mode: deploys lldap + a registration service. + # When false, configure auth.ldap below to point at your existing LDAP and + # disable the registration UI. + bundledLdap: + enabled: true + ldap: + # Defaults below assume bundled mode. Override for BYO LDAP. + host: loculus-lldap-service + port: 3890 + baseDn: dc=loculus,dc=org + userBaseDn: ou=people,dc=loculus,dc=org + groupBaseDn: ou=groups,dc=loculus,dc=org + userFilter: "(&(uid={input})(objectClass=person))" + bindDn: "uid=admin,ou=people,dc=loculus,dc=org" + # bindPassword comes from the lldap-secrets/adminPassword key in + # bundled mode; override secret name+key for BYO mode. insecureCookies: false bannerMessage: "This is a demonstration environment. It may contain non-accurate test data and should not be used for real-world applications. Data will be deleted regularly." welcomeMessageHTML: null @@ -2663,6 +2680,9 @@ images: backend: repository: "ghcr.io/loculus-project/backend" pullPolicy: Always + registrationService: + repository: "ghcr.io/loculus-project/registration-service" + pullPolicy: Always silo: apiThreadsForHttpConnections: 16 secrets: @@ -2670,24 +2690,60 @@ secrets: type: raw data: secretKey: not_configured - backend-keycloak-client-secret: - type: autogen + lldap-secrets: + type: raw + data: + adminPassword: "lldap-admin-password" + jwtSecret: "loculus-lldap-jwt-secret-please-rotate-me" + keySeed: "loculus-lldap-key-seed-please-rotate-me" + authelia-secrets: + type: raw data: - backendKeycloakClientSecret: "" + sessionSecret: "loculus-authelia-session-secret-please-rotate-me-please-rotate" + storageEncryptionKey: "loculus-authelia-storage-encryption-key-please-rotate-me-pls-rot" + jwtSecret: "loculus-authelia-jwt-secret-please-rotate-me-please-rotate-me!!" + oidcHmacSecret: "loculus-authelia-oidc-hmac-secret-please-rotate-me-please-rotate" + # The website's OIDC client secret is confidential because dev/CI uses + # http:// redirect URIs (Authelia forbids http on public clients). + # Plaintext + matching PBKDF2-SHA512 PHC hash live here so the website + # (which sends plaintext to /oauth2/token) and Authelia (which + # verifies against the hash) stay in sync. Rotate in production. + backendClientSecretPlain: "loculus-dev-client-secret" + backendClientSecretHash: "$pbkdf2-sha512$310000$hrjXuM5amwGWiA9lY.NC/A$bVLRb740tHQANA1ozr6HOwgVtSJE/qw4tkPwQ7eV5vltV5rcgv2R.P/LNm4FR95acZK9qmkJMKn9sy5DIun6JQ" + oidcIssuerPrivateKey: | + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEApLkfmwhUv9fjisakHUrt6qzOfGXmwwTsdFRKqJ3I5505WWHH + 5MHHZyZkwkNQNTm13T6B3ZAm5xe1xCpTL4YJ9fKFIaqTlmuB6v4kEisxtt/U1+Pc + 03GMlsiYNg8JfP+9ruV9YRaNJMQjI5zmVRp/BjErP/hs+Ir4mfqlzvZC/ZMvR4qa + T+UODzhp4EXy1pKVSoeVndHrx5bgENWkDfoymxMP/jYg5ALirme1SUOUdkBkgca8 + TcnFs03Sc4F+sDPz7pwVJDLvNDhItnPLPyO0poX3xaOdne6ctL5Dx81n7GeVyMLy + w0IjR/cg4h9RbRB7JN/kl27QFxLyXNN0cE0gXwIDAQABAoIBAGkLSRVzlaAVi5yX + Glc3zksGUlNQJH2fKS7yBf5LSLAzOjw+t9uwm3mzKTQc+wxGNizVzLk/UR+zpg1n + tE6tGrMGKlIS1jVpb5ss4FHZy8VELhZS0CLi2XXai/6FTlaxPARJgtAkMnQMxB/U + 0anZ0MFhH6SWxt8kuG5xQcWek4/ig2c46J8BgkdB/Dq/SmqipYt+PAy68pgLL8Tl + eKTHOkUZfq/hWQzgBAK1IQQHLerjzg/ehNM4hFpqhB6nEEugKZa9crc1D1VKKosz + UXeoQNj0aGvmRdY6kbZ746tUMSiTrP2FirDAFbfkwxzd/zWHB2yAgJE9m/k9ZSy2 + Yk65WcECgYEA1wZE80gAgAtsStcOdBadQaSdvGuwM4MnRDJxhplo6oZpJyFeFIyT + jLZjZp/g1jRdOM3fm7HVtyrbPzGIeK4UescYLk6Bp4WGWq7D1w7xarsgdZmMCw8H + mxEfHfEYR+xftGINeBGD9wFOQNlNzzlyopPkNHfkqft3nAzrnwZtIyECgYEAxBz1 + aQrCOeIt7Ea2f6zb3Lv8F74nI4ku2Ipirz0uoyuBwj1TST7MS8cd3tEHPexRHcaI + Z9foLRGUDCLH7B/Hips2S72zWNsgNojTDfn50kt1xOm7J1XtYiH3mRl7TdmxV9Q6 + cVCCWvOwFB8I3qfWyDhvNo896qY0X09KJYolU38CgYA9GfP36cryl8xjC+94f4Ca + SavlAfjk+mzrDSaDaA6PLjitPOceEcBP6PggDmh2lhSzcpULCiK/1PbOY0XzfQwm + w3KUngxrzR6boDPYZc+mU5xqroJEFjZEEz5zZLJQpdOgT4iiSN/mDcHt3ZIlw55W + oo3jdvpMbz/S4T0HSG004QKBgAw63hcR47DmaQS+GC14IzHtyzfT1O8DZBd+8c6J + 2zmzweDSIDqGHwluvm8hy/jRnvFjayhGr6T33qqvFJamGLSNH2KzztZwu96Kw9aw + SyMRL6P7C3/VfPtMsPssOqNNOyEGDCr64VY4sjdfBBeWke9kjZLydeMHQGbdM/uJ + VPCTAoGBAI/6DYTcO+AIiLVGFobjD86ZthkrGLjePqFSFv38sTktKhBVKmDxM/PY + KpYO8YI8HMgrDu8J0VJWI2kYmUBm4i1obAIwMSTj5fSll15sSmpM5oYG4z4Pmgl5 + wB9NDbRW/EV7Uurot9acKlyl985j5T+THxi5ALkcfCdg0/9cnHq7 + -----END RSA PRIVATE KEY----- database: type: raw data: url: "jdbc:postgresql://loculus-database-service/loculus" username: "postgres" password: "password" - keycloak-database: - type: raw - data: - addr: "loculus-keycloak-database-service" - database: "keycloak" - username: "postgres" - password: "unsecure" - port: "5432" crossref: type: raw data: @@ -2700,14 +2756,6 @@ secrets: preprocessingPipelinePassword: "" externalMetadataUpdaterPassword: "" backendUserPassword: "" - keycloak-admin: - type: raw - data: - initialAdminPassword: "admin" - orcid: - type: raw - data: - orcidSecret: "dummy" ingest-ncbi: type: raw data: @@ -2728,12 +2776,12 @@ secrets: data: accessKey: "dummyAccessKey" secretKey: "dummySecretKey" -runDevelopmentKeycloakDatabase: true runDevelopmentMainDatabase: true runDevelopmentS3: true developmentDatabasePersistence: false enforceHTTPS: true traefikVersion: 2 +ingressClassName: traefik registrationTermsMessage: > You must agree to the terms of use. @@ -2793,12 +2841,24 @@ resources: limits: cpu: "1" memory: "10Gi" - keycloak: + authelia: requests: - memory: "700Mi" - cpu: "40m" + memory: "100Mi" + cpu: "20m" limits: - memory: "3Gi" + memory: "512Mi" + lldap: + requests: + memory: "50Mi" + cpu: "10m" + limits: + memory: "256Mi" + registration-service: + requests: + memory: "100Mi" + cpu: "20m" + limits: + memory: "512Mi" silo: requests: memory: "200Mi" diff --git a/kubernetes/loculus/values_e2e_and_dev.yaml b/kubernetes/loculus/values_e2e_and_dev.yaml index 032af7efee..01c311ce44 100644 --- a/kubernetes/loculus/values_e2e_and_dev.yaml +++ b/kubernetes/loculus/values_e2e_and_dev.yaml @@ -2,16 +2,26 @@ secrets: service-accounts: type: raw data: - insdcIngestUserPassword: "insdc_ingest_user" - preprocessingPipelinePassword: "preprocessing_pipeline" - externalMetadataUpdaterPassword: "external_metadata_updater" - backendUserPassword: "backend" + # lldap rejects passwords shorter than 8 chars; the e2e service-account + # passwords also double as X-Service-Token values the backend trusts, so + # they need to be specific and ≥ 8 chars. + insdcIngestUserPassword: "insdc_ingest_user_devpw" + preprocessingPipelinePassword: "preprocessing_pipeline_devpw" + externalMetadataUpdaterPassword: "external_metadata_updater_devpw" + backendUserPassword: "backend_devpw_for_tests" createTestAccounts: true backendExtraArgs: - "--loculus.debug-mode=true" disableEnaSubmission: true auth: verifyEmail: false -host: localhost:3000 + relaxOidcTokenRateLimit: true +insecureCookies: true +# `.test` is reserved for testing per RFC 6761 — browsers don't auto-resolve +# (developers add a /etc/hosts entry; CI does the same), but unlike `.localhost` +# glibc inside containers doesn't hardcode-resolve it to 127.0.0.1, so cluster +# pods can route through CoreDNS to traefik. +host: loculus.test:3000 +subdomainSeparator: "." siloImport: pollIntervalSeconds: 5 diff --git a/preprocessing/dummy/main.py b/preprocessing/dummy/main.py index e3a2b9cc14..a22b37f079 100644 --- a/preprocessing/dummy/main.py +++ b/preprocessing/dummy/main.py @@ -96,7 +96,7 @@ def fetch_unprocessed_sequences(etag: str | None, n: int) -> tuple[str | None, l url = backendHost + "/extract-unprocessed-data" params = {"numberOfSequenceEntries": n, "pipelineVersion": pipeline_version} headers = { - "Authorization": "Bearer " + get_jwt(), + **service_token_header(), **({"If-None-Match": etag} if etag else {}), } response = requests.post(url, data=params, headers=headers) @@ -238,7 +238,7 @@ def submit_processed_sequences(processed: list[Sequence]): ndjson_string = "\n".join(json_strings) logging.info(ndjson_string) url = backendHost + "/submit-processed-data?pipelineVersion=" + str(pipeline_version) - headers = {"Content-Type": "application/x-ndjson", "Authorization": "Bearer " + get_jwt()} + headers = {"Content-Type": "application/x-ndjson", **service_token_header()} response = requests.post(url, data=ndjson_string, headers=headers) if not response.ok: raise Exception( @@ -248,17 +248,14 @@ def submit_processed_sequences(processed: list[Sequence]): def get_jwt(): - url = keycloakHost + keycloakTokenPath - data = { - "client_id": "backend-client", - "username": keycloakUser, - "password": keycloakPassword, - "grant_type": "password", - } - response = requests.post(url, data=data) - if not response.ok: - raise Exception(f"Fetching JWT failed. Status code: {response.status_code}", response.text) - return response.json()["access_token"] + # Backwards-compatible name; we now use a static service token. Returning + # it here lets the existing `**service_token_header()` + # call sites keep working without changes to every header dict. + return keycloakPassword + + +def service_token_header(): + return {"X-Service-Token": keycloakPassword} def main(): diff --git a/preprocessing/nextclade/src/loculus_preprocessing/backend.py b/preprocessing/nextclade/src/loculus_preprocessing/backend.py index afd6687120..29a784901d 100644 --- a/preprocessing/nextclade/src/loculus_preprocessing/backend.py +++ b/preprocessing/nextclade/src/loculus_preprocessing/backend.py @@ -11,7 +11,6 @@ from pathlib import Path from urllib.parse import urlparse -import jwt import pytz import requests @@ -46,32 +45,16 @@ def set_token(self, token: str, expiration: dt.datetime): jwt_cache = JwtCache() -def get_jwt(config: Config) -> str: - if cached_token := jwt_cache.get_token(): - logger.debug("Using cached JWT") - return cached_token +def auth_headers(config: Config) -> dict[str, str]: + """Return the pre-shared service-token header. - url = config.keycloak_host.rstrip("/") + "/" + config.keycloak_token_path.lstrip("/") - data = { - "client_id": "backend-client", - "username": config.keycloak_user, - "password": config.keycloak_password, - "grant_type": "password", - } - - logger.debug(f"Requesting JWT from {url}") - - with requests.post(url, data=data, timeout=10) as response: - if response.ok: - logger.debug("JWT fetched successfully.") - token = response.json()["access_token"] - decoded = jwt.decode(token, options={"verify_signature": False}) - expiration = dt.datetime.fromtimestamp(decoded.get("exp", 0), tz=pytz.UTC) - jwt_cache.set_token(token, expiration) - return token - error_msg = f"Fetching JWT failed with status code {response.status_code}: {response.text}" - logger.error(error_msg) - raise Exception(error_msg) + Authelia's OIDC provider does not support OAuth2 ROPC, so service-to- + service authentication uses an X-Service-Token header against the + backend's static filter instead of getting a JWT from the IDP. The + token value is the same secret the previous Keycloak password used, + rebound to a Spring Boot property in the backend. + """ + return {"X-Service-Token": config.keycloak_password} def parse_ndjson(ndjson_data: str) -> Sequence[UnprocessedEntry]: @@ -120,7 +103,7 @@ def fetch_unprocessed_sequences( logger.debug(f"[{request_id}] Fetching {n} unprocessed sequences from {url}") params = {"numberOfSequenceEntries": n, "pipelineVersion": config.pipeline_version} headers = { - "Authorization": "Bearer " + get_jwt(config), + **auth_headers(config), "x-request-id": request_id, **({"If-None-Match": etag} if etag else {}), } @@ -169,7 +152,7 @@ def submit_processed_sequences( url = config.backend_host.rstrip("/") + "/submit-processed-data" headers = { "Content-Type": "application/x-ndjson", - "Authorization": "Bearer " + get_jwt(config), + **auth_headers(config), "x-request-id": request_id, } params = {"pipelineVersion": config.pipeline_version} @@ -198,7 +181,7 @@ def request_upload(group_id: int, number_of_files: int, config: Config) -> Seque url = base_url + "/files/request-upload" params = {"groupId": group_id, "numberFiles": number_of_files} headers = { - "Authorization": "Bearer " + get_jwt(config), + **auth_headers(config), "x-request-id": request_id, } logger.info( diff --git a/registration-service/Dockerfile b/registration-service/Dockerfile new file mode 100644 index 0000000000..6942d8dabb --- /dev/null +++ b/registration-service/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends musl \ + && rm -rf /var/lib/apt/lists/* +COPY --from=lldap/lldap:v0.6.1 /app/lldap_set_password /usr/local/bin/lldap_set_password +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +COPY templates ./templates +COPY static ./static +EXPOSE 8090 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"] diff --git a/registration-service/README.md b/registration-service/README.md new file mode 100644 index 0000000000..633e13459a --- /dev/null +++ b/registration-service/README.md @@ -0,0 +1,28 @@ +# Loculus registration service + +Small FastAPI service that renders a registration form and creates users in +[lldap](https://github.com/lldap/lldap) via its GraphQL admin API. + +Deployed alongside lldap when `auth.bundledLdap.enabled` is true in the Helm +chart. In BYO-LDAP mode (operator points Authelia at an existing LDAP) this +service is not deployed, and registration is managed out-of-band. + +## Environment + +| Variable | Purpose | +| ---------------------- | ------------------------------------------------------------ | +| `LLDAP_URL` | lldap HTTP base URL, e.g. `http://loculus-lldap-service:17170` | +| `LLDAP_ADMIN_USERNAME` | admin username (typically `admin`) | +| `LLDAP_ADMIN_PASSWORD` | admin password | +| `LOGIN_URL` | Authelia URL used in success redirect + login link | +| `TERMS_MESSAGE` | HTML terms-of-service blurb shown above the form | +| `DEFAULT_GROUP` | Group new users are added to (defaults to `user`) | + +## Run locally + +```sh +LLDAP_URL=http://localhost:17170 \ +LLDAP_ADMIN_USERNAME=admin \ +LLDAP_ADMIN_PASSWORD=admin-password \ + uvicorn main:app --reload --port 8090 +``` diff --git a/registration-service/main.py b/registration-service/main.py new file mode 100644 index 0000000000..600ddda627 --- /dev/null +++ b/registration-service/main.py @@ -0,0 +1,296 @@ +"""Loculus registration service. + +Renders a registration form and creates the user in lldap via its GraphQL +admin API. Designed to be deployed alongside lldap in bundled-LDAP mode. +""" +from __future__ import annotations + +import asyncio +import os +import re +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional + +import httpx +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + + +LLDAP_URL = os.environ["LLDAP_URL"].rstrip("/") +LLDAP_ADMIN_USERNAME = os.environ["LLDAP_ADMIN_USERNAME"] +LLDAP_ADMIN_PASSWORD = os.environ["LLDAP_ADMIN_PASSWORD"] +LOGIN_URL = os.environ.get("LOGIN_URL", "") +TERMS_MESSAGE = os.environ.get("TERMS_MESSAGE", "") +DEFAULT_GROUP = os.environ.get("DEFAULT_GROUP", "user") + +USERNAME_RE = re.compile(r"^[a-z0-9_-]{3,32}$") +# The character classes here are linear (single-char only, no `+`/`*` quantifier +# stacking), but we still cap MAX_EMAIL_LEN before applying the regex to keep +# matching predictably bounded on attacker-supplied input. +EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,253}\.[^@\s]{1,253}$") +MAX_EMAIL_LEN = 254 + + +class LldapClient: + def __init__(self, base_url: str, username: str, password: str) -> None: + self._base = base_url + self._username = username + self._password = password + self._token: Optional[str] = None + self._default_group_id: Optional[int] = None + self._default_group_id_loaded = False + self._login_lock = asyncio.Lock() + self._operation_lock = asyncio.Lock() + self._http = httpx.AsyncClient(base_url=base_url, timeout=15.0) + + async def aclose(self) -> None: + await self._http.aclose() + + @asynccontextmanager + async def operation(self) -> AsyncIterator[None]: + async with self._operation_lock: + yield + + async def _login(self) -> str: + # lldap 0.6 expects `username` (not `name`). + resp = await self._http.post( + "/auth/simple/login", + json={"username": self._username, "password": self._password}, + ) + resp.raise_for_status() + return resp.json()["token"] + + async def _get_token(self) -> str: + if self._token is not None: + return self._token + + async with self._login_lock: + if self._token is None: + self._token = await self._login() + return self._token + + async def _refresh_token(self) -> str: + async with self._login_lock: + self._token = await self._login() + return self._token + + async def _gql(self, query: str, variables: dict) -> dict: + token = await self._get_token() + resp = await self._http.post( + "/api/graphql", + json={"query": query, "variables": variables}, + headers={"Authorization": f"Bearer {token}"}, + ) + if resp.status_code == 401: + # token expired, retry once + token = await self._refresh_token() + resp = await self._http.post( + "/api/graphql", + json={"query": query, "variables": variables}, + headers={"Authorization": f"Bearer {token}"}, + ) + resp.raise_for_status() + return resp.json() + + async def _get_default_group_id(self) -> Optional[int]: + if self._default_group_id_loaded: + return self._default_group_id + + groups = await self._gql("query { groups { id displayName } }", {}) + self._default_group_id = next( + ( + g["id"] + for g in groups["data"]["groups"] + if g["displayName"] == DEFAULT_GROUP + ), + None, + ) + self._default_group_id_loaded = True + return self._default_group_id + + async def _set_password(self, user_id: str, password: str) -> None: + token = await self._get_token() + process = await asyncio.create_subprocess_exec( + "lldap_set_password", + "--base-url", + f"{self._base}/", + "--token", + token, + "--username", + user_id, + "--password", + password, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30) + except asyncio.TimeoutError as exc: + process.kill() + await process.communicate() + raise RuntimeError("Timed out while setting lldap password") from exc + + if process.returncode != 0: + output = (stderr or stdout).decode(errors="replace").strip() + raise RuntimeError(f"Failed to set lldap password: {output}") + + async def user_exists(self, user_id: str) -> bool: + q = "query($id: String!) { user(userId: $id) { id } }" + body = await self._gql(q, {"id": user_id}) + data = body.get("data") + if data is None: + errors = body.get("errors") or [] + if any("Entity not found" in (error.get("message") or "") for error in errors): + return False + raise RuntimeError(f"Unexpected lldap GraphQL response: {body}") + return data.get("user") is not None + + async def email_exists(self, email: str) -> bool: + q = "query { users { email } }" + body = await self._gql(q, {}) + return any( + (u.get("email") or "").lower() == email.lower() + for u in (body.get("data") or {}).get("users", []) + ) + + async def create_user( + self, + user_id: str, + email: str, + first_name: str, + last_name: str, + organization: str, + password: str, + ) -> None: + gid = await self._get_default_group_id() + q = """ + mutation($u: CreateUserInput!) { + createUser(user: $u) { id } + } + """ + await self._gql( + q, + { + "u": { + "id": user_id, + "email": email, + "displayName": f"{first_name} {last_name}".strip() or user_id, + "firstName": first_name, + "lastName": last_name, + } + }, + ) + if gid is not None: + await self._gql( + "mutation($u: String!, $g: Int!) { addUserToGroup(userId: $u, groupId: $g) { ok } }", + {"u": user_id, "g": gid}, + ) + await self._set_password(user_id, password) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + client = LldapClient(LLDAP_URL, LLDAP_ADMIN_USERNAME, LLDAP_ADMIN_PASSWORD) + app.state.lldap = client + try: + yield + finally: + await client.aclose() + + +app = FastAPI(lifespan=lifespan, title="Loculus registration service") +templates = Jinja2Templates(directory="templates") +app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.get("/health") +async def health() -> dict: + return {"ok": True} + + +@app.get("/", response_class=HTMLResponse) +async def form(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request, + "register.html", + { + "errors": {}, + "values": {}, + "terms_message": TERMS_MESSAGE, + "login_url": LOGIN_URL, + }, + ) + + +@app.post("/") +async def submit( + request: Request, + username: str = Form(...), + email: str = Form(...), + first_name: str = Form(...), + last_name: str = Form(...), + organization: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), + accept_terms: Optional[str] = Form(None), +): + errors: dict[str, str] = {} + values = { + "username": username, + "email": email, + "first_name": first_name, + "last_name": last_name, + "organization": organization, + } + if not USERNAME_RE.fullmatch(username): + errors["username"] = "3-32 chars, lowercase letters, digits, _, -" + if len(email) > MAX_EMAIL_LEN or not EMAIL_RE.fullmatch(email): + errors["email"] = "Invalid email" + if not first_name.strip(): + errors["first_name"] = "Required" + if not last_name.strip(): + errors["last_name"] = "Required" + if not organization.strip(): + errors["organization"] = "Required" + if len(password) < 8: + errors["password"] = "At least 8 characters" + elif password != confirm_password: + errors["confirm_password"] = "Passwords do not match" + if not accept_terms: + errors["accept_terms"] = "You must accept the terms" + + lldap: LldapClient = request.app.state.lldap + if not errors: + async with lldap.operation(): + if await lldap.user_exists(username): + errors["username"] = "That username is already taken" + elif await lldap.email_exists(email): + errors["email"] = "That email is already registered" + else: + await lldap.create_user( + user_id=username, + email=email, + first_name=first_name.strip(), + last_name=last_name.strip(), + organization=organization.strip(), + password=password, + ) + + if errors: + return templates.TemplateResponse( + request, + "register.html", + { + "errors": errors, + "values": values, + "terms_message": TERMS_MESSAGE, + "login_url": LOGIN_URL, + }, + status_code=400, + ) + + if LOGIN_URL: + return RedirectResponse(url=f"{LOGIN_URL}?registered=1", status_code=303) + return RedirectResponse(url="/?registered=1", status_code=303) diff --git a/registration-service/requirements.txt b/registration-service/requirements.txt new file mode 100644 index 0000000000..ac4bbebd66 --- /dev/null +++ b/registration-service/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +httpx==0.28.1 +jinja2==3.1.4 +python-multipart==0.0.20 +uvicorn[standard]==0.34.0 diff --git a/registration-service/static/style.css b/registration-service/static/style.css new file mode 100644 index 0000000000..967e9bc941 --- /dev/null +++ b/registration-service/static/style.css @@ -0,0 +1,65 @@ +:root { + font-family: system-ui, sans-serif; + color: #111; +} +body { + margin: 0; + background: #f6f7f9; +} +.register { + max-width: 28rem; + margin: 4rem auto; + padding: 2rem; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} +.register h1 { + margin-top: 0; +} +.terms { + font-size: 0.9rem; + color: #555; +} +label { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0.75rem 0; + font-size: 0.9rem; +} +label.checkbox { + flex-direction: row; + align-items: center; + gap: 0.5rem; +} +input[type="text"], +input[type="email"], +input[type="password"] { + padding: 0.5rem 0.6rem; + border: 1px solid #d0d4da; + border-radius: 4px; + font: inherit; +} +.err { + color: #b00020; + font-size: 0.8rem; +} +button { + margin-top: 1rem; + padding: 0.6rem 1rem; + background: #2563eb; + color: #fff; + border: 0; + border-radius: 4px; + font: inherit; + cursor: pointer; +} +button:hover { + background: #1d4fc7; +} +.login-link { + margin-top: 1.25rem; + font-size: 0.9rem; + color: #555; +} diff --git a/registration-service/templates/register.html b/registration-service/templates/register.html new file mode 100644 index 0000000000..67e4769d7d --- /dev/null +++ b/registration-service/templates/register.html @@ -0,0 +1,117 @@ + + + + + + Register + + + +
+

Create a Loculus account

+ {% if terms_message %} +

{{ terms_message|safe }}

+ {% endif %} +
+ + + + + + + + + +
+ {% if login_url %} + + {% endif %} +
+ + diff --git a/website/.dockerignore.notes b/website/.dockerignore.notes new file mode 100644 index 0000000000..4248b74463 --- /dev/null +++ b/website/.dockerignore.notes @@ -0,0 +1 @@ +# Authelia migration — fresh build to break a website-image cache collision diff --git a/website/src/components/Navigation/Navigation.astro b/website/src/components/Navigation/Navigation.astro index 6db0397ae5..60257e708b 100644 --- a/website/src/components/Navigation/Navigation.astro +++ b/website/src/components/Navigation/Navigation.astro @@ -4,7 +4,7 @@ import { NavigationTab } from './NavigationTab.tsx'; import { OrganismNavigation } from './OrganismNavigation.tsx'; import { SandwichMenu } from './SandwichMenu.tsx'; import { cleanOrganism } from './cleanOrganism'; -import { getWebsiteConfig } from '../../config'; +import { getRuntimeConfig, getWebsiteConfig } from '../../config'; import { navigationItems } from '../../routes/navigationItems'; import { getAuthUrl } from '../../utils/getAuthUrl'; @@ -24,8 +24,9 @@ const siteName = websiteConfig.name; const isLoggedIn = Astro.locals.session?.isLoggedIn ?? false; const loginUrl = await getAuthUrl(Astro.url.toString()); +const registrationUrl = getRuntimeConfig().serverSide.registrationUrl; -const topNavigationItems = navigationItems.top(isLoggedIn, loginUrl); +const topNavigationItems = navigationItems.top(isLoggedIn, loginUrl, registrationUrl); const accessionPrefix = getWebsiteConfig().accessionPrefix; --- diff --git a/website/src/components/User/UserPage.astro b/website/src/components/User/UserPage.astro index 2baa8944fd..735efe4c19 100644 --- a/website/src/components/User/UserPage.astro +++ b/website/src/components/User/UserPage.astro @@ -3,9 +3,8 @@ import { ListOfGroupsOfUser } from './ListOfGroupsOfUser.tsx'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { routes } from '../../routes/routes'; import { GroupManagementClient } from '../../services/groupManagementClient'; -import { KeycloakClientManager } from '../../utils/KeycloakClientManager'; import { getAccessToken } from '../../utils/getAccessToken'; -import { getUrlForKeycloakAccountPage } from '../../utils/getAuthUrl.ts'; +import { getUrlForAccountPage } from '../../utils/getAuthUrl.ts'; import ErrorBox from '../common/ErrorBox.tsx'; import DashiconsGroups from '~icons/dashicons/groups'; import IconoirOpenNewWindow from '~icons/iconoir/open-new-window'; @@ -16,13 +15,7 @@ const user = session.user!; // page only accessible if user is logged in const username = user.username!; // all users must have a username const name = user.name; const accessToken = getAccessToken(session)!; -const logoutUrl = new URL(Astro.request.url); -logoutUrl.pathname = routes.logout(); -const keycloakClient = await KeycloakClientManager.getClient(); -const keycloakLogoutUrl = keycloakClient!.endSessionUrl({ - post_logout_redirect_uri: logoutUrl.href, // eslint-disable-line @typescript-eslint/naming-convention -}); -const accountPageUrl = await getUrlForKeycloakAccountPage(); +const accountPageUrl = getUrlForAccountPage(); const groupOfUsersResult = await GroupManagementClient.create().getGroupsOfUser(accessToken); --- @@ -47,7 +40,7 @@ const groupOfUsersResult = await GroupManagementClient.create().getGroupsOfUser(
Logout { return next(); } - const client = await KeycloakClientManager.getClient(); + const client = await OidcClientManager.getClient(); if (client !== undefined) { - // Only run this when keycloak up + // Only run this when OIDC client up const cookieResult = await getValidTokenAndUserInfoFromCookie(context, client); token = cookieResult?.token; userInfo = cookieResult?.userInfo; @@ -91,12 +93,17 @@ export const authMiddleware = defineMiddleware(async (context, next) => { if (token !== undefined) { logger.debug(`Token found in params, setting cookie`); - setCookie(context, token); - return createRedirectWithModifiableHeaders(removeTokenCodeFromSearchParams(context.url)); + const cookieHeaders = setCookie(context, token); + // OIDC roundtrip lands on /auth/callback; the original + // destination is encoded in `state`. Fall back to the same + // URL with code/state stripped (covers any legacy flow). + const decoded = decodeState(context.url.searchParams.get('state') ?? undefined); + const returnTo = decoded?.r ?? removeTokenCodeFromSearchParams(context.url); + return createRedirectWithModifiableHeaders(returnTo, cookieHeaders); } } } else { - logger.warn(`Keycloak client not available, pretending user logged out`); + logger.warn(`OIDC client not available, pretending user logged out`); } const enforceLogin = shouldMiddlewareEnforceLogin( @@ -106,7 +113,7 @@ export const authMiddleware = defineMiddleware(async (context, next) => { if (enforceLogin && (userInfo === undefined || userInfo.isErr())) { if (client === undefined) { - logger.error(`Keycloak client not available, cannot redirect to auth`); + logger.error(`OIDC client not available, cannot redirect to auth`); return context.redirect('/503?service=Authentication'); } return redirectToAuth(context); @@ -146,6 +153,7 @@ export const authMiddleware = defineMiddleware(async (context, next) => { async function getTokenFromCookie(context: APIContext, client: BaseClient) { const accessToken = context.cookies.get(ACCESS_TOKEN_COOKIE)?.value; + const oidcAccessToken = context.cookies.get(OIDC_ACCESS_TOKEN_COOKIE)?.value; const refreshToken = context.cookies.get(REFRESH_TOKEN_COOKIE)?.value; if (accessToken === undefined || refreshToken === undefined) { @@ -153,13 +161,14 @@ async function getTokenFromCookie(context: APIContext, client: BaseClient) { } const tokenCookie = { accessToken, + oidcAccessToken, refreshToken, }; const verifiedTokenResult = await verifyToken(accessToken, client); if (verifiedTokenResult.isErr() && verifiedTokenResult.error.type === TokenVerificationError.EXPIRED) { logger.debug(`Token expired, trying to refresh`); - return refreshTokenViaKeycloak(tokenCookie, client); + return refreshTokenViaOidc(tokenCookie, client); } if (verifiedTokenResult.isErr()) { logger.info(`Error verifying token: ${verifiedTokenResult.error.message}`); @@ -175,21 +184,20 @@ async function verifyToken(accessToken: string, client: BaseClient) { const tokenHeader = jsonwebtoken.decode(accessToken, { complete: true })?.header; const kid = tokenHeader?.kid; if (kid === undefined) { - return err({ - type: TokenVerificationError.INVALID_TOKEN, - message: 'Token does not contain kid', - }); + logger.debug(`Access token is opaque; deferring validation to userinfo`); + return ok(undefined); } if (client.issuer.metadata.jwks_uri === undefined) { return err({ type: TokenVerificationError.REQUEST_ERROR, - message: `Keycloak client does not contain jwks_uri: ${JSON.stringify(client.issuer.metadata.jwks_uri)}`, + message: `OIDC client does not contain jwks_uri: ${JSON.stringify(client.issuer.metadata.jwks_uri)}`, }); } const jwksClient = new JwksRsa.JwksClient({ jwksUri: client.issuer.metadata.jwks_uri, + requestHeaders: getAutheliaForwardedHeaders(), }); try { @@ -219,7 +227,7 @@ async function verifyToken(accessToken: string, client: BaseClient) { } async function getUserInfo(token: TokenCookie, client: BaseClient) { - return ResultAsync.fromPromise(client.userinfo(token.accessToken), (error) => { + return ResultAsync.fromPromise(client.userinfo(token.oidcAccessToken ?? token.accessToken), (error) => { logger.debug(`Error getting user info: ${error}`); return error; }); @@ -227,16 +235,32 @@ async function getUserInfo(token: TokenCookie, client: BaseClient) { async function getTokenFromParams(context: APIContext, client: BaseClient): Promise { const params = client.callbackParams(context.url.toString()); - logger.debug(`Keycloak callback params: ${JSON.stringify(params)}`); + logger.debug(`OIDC callback params: ${JSON.stringify(params)}`); if (params.code !== undefined) { - const redirectUri = removeTokenCodeFromSearchParams(context.url); - logger.debug(`Keycloak callback redirect uri: ${redirectUri}`); + // The redirect_uri sent on the token exchange must match the one from + // the original authorize request exactly. Our authorize call uses the + // bare /auth/callback URL (no query string), so reconstruct that here + // regardless of which extra params the IDP appended on its way back. + const callbackUrl = new URL(context.url.toString()); + callbackUrl.search = ''; + callbackUrl.hash = ''; + const redirectUri = callbackUrl.toString(); + logger.debug(`OIDC callback redirect uri: ${redirectUri}`); + const decoded = decodeState(params.state); + if (!decoded) { + logger.info('OIDC callback received without a recognisable state payload'); + return undefined; + } const tokenSet = await client .callback(redirectUri, params, { - response_type: 'code', // eslint-disable-line @typescript-eslint/naming-convention + // eslint-disable-next-line @typescript-eslint/naming-convention + response_type: 'code', + state: params.state, + // eslint-disable-next-line @typescript-eslint/naming-convention + code_verifier: decoded.v, }) .catch((error: unknown) => { - logger.info(`Keycloak callback error: ${error}`); + logger.info(`OIDC callback error: ${error}`); return undefined; }); return extractTokenCookieFromTokenSet(tokenSet); @@ -244,39 +268,61 @@ async function getTokenFromParams(context: APIContext, client: BaseClient): Prom return undefined; } -function setCookie(context: APIContext, token: TokenCookie) { +function getTokenCookieOptions(): SerializeOptions { const runtimeConfig = getRuntimeConfig(); - logger.debug(`Setting token cookie`); - context.cookies.set(ACCESS_TOKEN_COOKIE, token.accessToken, { - httpOnly: true, - sameSite: 'lax', - secure: !runtimeConfig.insecureCookies, - path: '/', - }); - context.cookies.set(REFRESH_TOKEN_COOKIE, token.refreshToken, { + return { httpOnly: true, sameSite: 'lax', secure: !runtimeConfig.insecureCookies, path: '/', - }); + }; } -function deleteCookie(context: APIContext) { +function setCookie(context: APIContext, token: TokenCookie): string[] { + const cookieOptions = getTokenCookieOptions(); + logger.debug(`Setting token cookie`); + context.cookies.set(ACCESS_TOKEN_COOKIE, token.accessToken, cookieOptions); + if (token.oidcAccessToken !== undefined) { + context.cookies.set(OIDC_ACCESS_TOKEN_COOKIE, token.oidcAccessToken, cookieOptions); + } + context.cookies.set(REFRESH_TOKEN_COOKIE, token.refreshToken, cookieOptions); + const cookieHeaders = [ + serialize(ACCESS_TOKEN_COOKIE, token.accessToken, cookieOptions), + token.oidcAccessToken === undefined + ? undefined + : serialize(OIDC_ACCESS_TOKEN_COOKIE, token.oidcAccessToken, cookieOptions), + serialize(REFRESH_TOKEN_COOKIE, token.refreshToken, cookieOptions), + ]; + return cookieHeaders.filter((it): it is string => it !== undefined); +} + +function deleteCookie(context: APIContext): string[] { logger.debug(`Deleting token cookie`); try { context.cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' }); + context.cookies.delete(OIDC_ACCESS_TOKEN_COOKIE, { path: '/' }); context.cookies.delete(REFRESH_TOKEN_COOKIE, { path: '/' }); } catch { logger.info(`Error deleting cookie`); } + const deleteOptions: SerializeOptions = { path: '/', maxAge: 0 }; + return [ + serialize(ACCESS_TOKEN_COOKIE, '', deleteOptions), + serialize(OIDC_ACCESS_TOKEN_COOKIE, '', deleteOptions), + serialize(REFRESH_TOKEN_COOKIE, '', deleteOptions), + ]; } // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Basic_concepts#guard // URL must be absolute, otherwise throws TypeError -const createRedirectWithModifiableHeaders = (url: string) => { +const createRedirectWithModifiableHeaders = (url: string, cookieHeaders: string[] = []) => { logger.debug(`Redirecting to ${url}`); const redirect = Response.redirect(url); - return new Response(null, { status: redirect.status, headers: redirect.headers }); + const response = new Response(null, { status: redirect.status, headers: redirect.headers }); + for (const cookie of cookieHeaders) { + response.headers.append('set-cookie', cookie); + } + return response; }; const redirectToAuth = async (context: APIContext) => { @@ -286,8 +332,8 @@ const redirectToAuth = async (context: APIContext) => { logger.debug(`Redirecting to auth with redirect url: ${redirectUrl}`); const authUrl = await getAuthUrl(redirectUrl); - deleteCookie(context); - return createRedirectWithModifiableHeaders(authUrl); + const cookieHeaders = deleteCookie(context); + return createRedirectWithModifiableHeaders(authUrl, cookieHeaders); }; function removeTokenCodeFromSearchParams(url: URL): string { @@ -300,7 +346,7 @@ function removeTokenCodeFromSearchParams(url: URL): string { return newUrl.toString(); } -async function refreshTokenViaKeycloak(token: TokenCookie, client: BaseClient): Promise { +async function refreshTokenViaOidc(token: TokenCookie, client: BaseClient): Promise { const refreshedTokenSet = await client.refresh(token.refreshToken).catch(() => { logger.info(`Failed to refresh token`); return undefined; @@ -309,7 +355,11 @@ async function refreshTokenViaKeycloak(token: TokenCookie, client: BaseClient): } function extractTokenCookieFromTokenSet(tokenSet: TokenSet | undefined): TokenCookie | undefined { - const accessToken = tokenSet?.access_token; + // Authelia access tokens are opaque. Loculus backend is a JWT resource + // server, so use the OIDC ID token for backend bearer auth and keep the + // opaque access token only for provider userinfo calls. + const accessToken = tokenSet?.id_token ?? tokenSet?.access_token; + const oidcAccessToken = tokenSet?.access_token; const refreshToken = tokenSet?.refresh_token; if (tokenSet === undefined || accessToken === undefined || refreshToken === undefined) { @@ -319,6 +369,7 @@ function extractTokenCookieFromTokenSet(tokenSet: TokenSet | undefined): TokenCo return { accessToken, + oidcAccessToken, refreshToken, }; } diff --git a/website/src/pages/api-documentation/index.astro b/website/src/pages/api-documentation/index.astro index 3d15959364..1613dd94f3 100644 --- a/website/src/pages/api-documentation/index.astro +++ b/website/src/pages/api-documentation/index.astro @@ -6,7 +6,7 @@ import { routes } from '../../routes/routes.ts'; import { getAuthBaseUrl } from '../../utils/getAuthUrl'; const clientConfig = getRuntimeConfig().public; -const keycloakUrl = getAuthBaseUrl(); +const authUrl = getAuthBaseUrl(); const websiteConfig = getWebsiteConfig(); @@ -104,13 +104,13 @@ const BUTTON_CLASS =
-

Keycloak server

+

Authentication

- We use the open source software Keycloak for authentication. + We use the open source software Authelia for authentication.
- URL of Keycloak server: - {keycloakUrl} + OIDC issuer URL: + {authUrl}
diff --git a/website/src/pages/loculus-info/index.ts b/website/src/pages/loculus-info/index.ts index 2ea0cb54cd..6fddff63de 100644 --- a/website/src/pages/loculus-info/index.ts +++ b/website/src/pages/loculus-info/index.ts @@ -12,15 +12,15 @@ const corsHeaders = { 'Access-Control-Allow-Headers': 'Content-Type', } as const; -export const GET: APIRoute = async ({ request }) => { +export const GET: APIRoute = ({ request }) => { const runtime = getRuntimeConfig(); const website = getWebsiteConfig(); - const keycloakUrl = await getAuthBaseUrl(); + const authUrl = getAuthBaseUrl(); const response = { hosts: { backend: runtime.public.backendUrl, lapis: runtime.public.lapisUrls, - keycloak: keycloakUrl, + authelia: authUrl, website: new URL(request.url).origin, }, minCliVersion: '0.0.0', diff --git a/website/src/pages/seqsets/index.astro b/website/src/pages/seqsets/index.astro index 69a9f2265b..138465a749 100644 --- a/website/src/pages/seqsets/index.astro +++ b/website/src/pages/seqsets/index.astro @@ -9,7 +9,7 @@ import { getRuntimeConfig, seqSetsAreEnabled } from '../../config'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { SeqSetCitationClient } from '../../services/seqSetCitationClient.ts'; import { getAccessToken } from '../../utils/getAccessToken'; -import { getUrlForKeycloakAccountPage } from '../../utils/getAuthUrl.ts'; +import { getUrlForAccountPage } from '../../utils/getAuthUrl.ts'; if (!seqSetsAreEnabled()) { return Astro.rewrite('/404'); @@ -24,7 +24,7 @@ const seqSetClient = SeqSetCitationClient.create(); const seqSetsResponse = await seqSetClient.getSeqSetsOfUser(accessToken); const authorResponse = await seqSetClient.getAuthor(username); -const editAccountUrl = (await getUrlForKeycloakAccountPage()) + '/#/personal-info'; +const editAccountUrl = getUrlForAccountPage() + '/#/personal-info'; --- diff --git a/website/src/routes/navigationItems.ts b/website/src/routes/navigationItems.ts index 873c042c8d..e3333789f3 100644 --- a/website/src/routes/navigationItems.ts +++ b/website/src/routes/navigationItems.ts @@ -60,28 +60,43 @@ function getSeqSetsItems() { ]; } -function getAccountItems(isLoggedIn: boolean, loginUrl: string) { +function getAccountItems(isLoggedIn: boolean, loginUrl: string, registrationUrl?: string) { if (!getWebsiteConfig().enableLoginNavigationItem || getWebsiteConfig().readOnlyMode) { return []; } - const accountItem = isLoggedIn - ? { - id: 'account', - text: 'My account', - path: routes.userOverviewPage(), - } - : { - id: 'login', - text: 'Login', - path: loginUrl, - }; - return [accountItem]; + if (isLoggedIn) { + return [ + { + id: 'account', + text: 'My account', + path: routes.userOverviewPage(), + }, + ]; + } + + const signedOutItems: TopNavigationItems = [ + { + id: 'login', + text: 'Login', + path: loginUrl, + }, + ]; + + if (registrationUrl !== undefined) { + signedOutItems.push({ + id: 'register', + text: 'Register', + path: registrationUrl, + }); + } + + return signedOutItems; } -function topNavigationItems(isLoggedIn: boolean, loginUrl: string) { +function topNavigationItems(isLoggedIn: boolean, loginUrl: string, registrationUrl?: string) { const seqSetsItems = getSeqSetsItems(); - const accountItems = getAccountItems(isLoggedIn, loginUrl); + const accountItems = getAccountItems(isLoggedIn, loginUrl, registrationUrl); return [...seqSetsItems, ...extraStaticTopNavigationItems, ...accountItems]; } diff --git a/website/src/types/runtimeConfig.ts b/website/src/types/runtimeConfig.ts index 1d196277bb..8ec38b198e 100644 --- a/website/src/types/runtimeConfig.ts +++ b/website/src/types/runtimeConfig.ts @@ -12,14 +12,16 @@ export type ClientConfig = z.infer; export const serverConfig = serviceUrls.merge( z.object({ - keycloakUrl: z.string(), + autheliaUrl: z.string(), + autheliaPublicUrl: z.string(), + registrationUrl: z.string().optional(), + oidcClientSecret: z.string(), }), ); export const runtimeConfig = z.object({ public: serviceUrls, serverSide: serverConfig, - backendKeycloakClientSecret: z.string().min(5), insecureCookies: z.boolean(), }); diff --git a/website/src/utils/KeycloakClientManager.ts b/website/src/utils/KeycloakClientManager.ts deleted file mode 100644 index 9f0c538b8c..0000000000 --- a/website/src/utils/KeycloakClientManager.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type BaseClient, Issuer } from 'openid-client'; - -import { getClientMetadata } from './clientMetadata.ts'; -import { realmPath } from './realmPath.ts'; -import { getRuntimeConfig } from '../config.ts'; -import { getInstanceLogger } from '../logger.ts'; - -let _keycloakClient: BaseClient | undefined; -const logger = getInstanceLogger('KeycloakClientManager'); - -export const KeycloakClientManager = { - getClient: async (): Promise => { - if (_keycloakClient !== undefined) { - return _keycloakClient; - } - - const originForClient = getRuntimeConfig().serverSide.keycloakUrl; - const issuerUrl = `${originForClient}${realmPath}`; - - logger.info(`Getting keycloak client for issuer url: ${issuerUrl}`); - - try { - const keycloakIssuer = await Issuer.discover(issuerUrl); - logger.info(`Keycloak issuer discovered: ${issuerUrl}`); - _keycloakClient = new keycloakIssuer.Client(getClientMetadata()); - } catch (error) { - // @ts-expect-error -- `code` maybe doesn't exist on error - if (error?.code !== 'ECONNREFUSED') { - logger.error(`Error discovering keycloak issuer: ${error}`); - throw error; - } - logger.warn(`Connection refused when trying to discover the keycloak issuer at url: ${issuerUrl}`); - } - - return _keycloakClient; - }, -}; diff --git a/website/src/utils/OidcClientManager.ts b/website/src/utils/OidcClientManager.ts new file mode 100644 index 0000000000..f97cbbf538 --- /dev/null +++ b/website/src/utils/OidcClientManager.ts @@ -0,0 +1,106 @@ +import { type OutgoingHttpHeaders } from 'http'; + +import { type BaseClient, type HttpOptions, Issuer, custom } from 'openid-client'; + +import { getClientMetadata } from './clientMetadata.ts'; +import { getRuntimeConfig } from '../config.ts'; +import { getInstanceLogger } from '../logger.ts'; + +let _client: BaseClient | undefined; +const logger = getInstanceLogger('OidcClientManager'); + +export function getAutheliaForwardedHeaders() { + const publicUrl = new URL(getRuntimeConfig().serverSide.autheliaPublicUrl); + return { + /* eslint-disable @typescript-eslint/naming-convention */ + 'X-Forwarded-Proto': publicUrl.protocol.replace(':', ''), + 'X-Forwarded-Host': publicUrl.host, + 'X-Forwarded-Port': publicUrl.port || (publicUrl.protocol === 'https:' ? '443' : '80'), + /* eslint-enable @typescript-eslint/naming-convention */ + }; +} + +// We construct the Authelia OIDC client from a fixed metadata table rather than +// running `.well-known/openid-configuration` discovery. Authelia derives the +// issuer URL from the incoming request's Host and X-Forwarded-Proto headers, +// and our server-side calls land directly on the in-cluster Authelia service +// (no proxy) so the issuer it returns is the internal http URL — which makes +// downstream token validation fail. Hardcoding sidesteps that. +function buildIssuer(internalUrl: string, publicUrl: string): Issuer { + const internal = internalUrl.replace(/\/+$/, ''); + const pub = publicUrl.replace(/\/+$/, ''); + return new Issuer({ + issuer: pub, + // eslint-disable-next-line @typescript-eslint/naming-convention + authorization_endpoint: `${pub}/api/oidc/authorization`, + // eslint-disable-next-line @typescript-eslint/naming-convention + token_endpoint: `${internal}/api/oidc/token`, + // eslint-disable-next-line @typescript-eslint/naming-convention + userinfo_endpoint: `${internal}/api/oidc/userinfo`, + // eslint-disable-next-line @typescript-eslint/naming-convention + jwks_uri: `${internal}/jwks.json`, + // eslint-disable-next-line @typescript-eslint/naming-convention + revocation_endpoint: `${internal}/api/oidc/revocation`, + // eslint-disable-next-line @typescript-eslint/naming-convention + introspection_endpoint: `${internal}/api/oidc/introspection`, + // eslint-disable-next-line @typescript-eslint/naming-convention + device_authorization_endpoint: `${pub}/api/oidc/device-authorization`, + }); +} + +function isRawHeaderList(headers: OutgoingHttpHeaders | readonly string[] | undefined): headers is readonly string[] { + return Array.isArray(headers); +} + +function normalizeHeaders(headers: OutgoingHttpHeaders | readonly string[] | undefined): OutgoingHttpHeaders { + if (!isRawHeaderList(headers)) { + return headers ?? {}; + } + + const normalized: OutgoingHttpHeaders = {}; + for (let index = 0; index < headers.length - 1; index += 2) { + normalized[headers[index]] = headers[index + 1]; + } + return normalized; +} + +function withAutheliaForwardedHeaders( + options: HttpOptions, + forwardedHeaders: ReturnType, +): HttpOptions { + return { + ...options, + headers: { + ...normalizeHeaders(options.headers), + ...forwardedHeaders, + }, + }; +} + +export const OidcClientManager = { + // Kept async for callsite compatibility (the previous implementation used + // `Issuer.discover`); building the client is now synchronous. + // eslint-disable-next-line @typescript-eslint/require-await + getClient: async (): Promise => { + if (_client !== undefined) { + return _client; + } + try { + const internal = getRuntimeConfig().serverSide.autheliaUrl; + const pub = getRuntimeConfig().serverSide.autheliaPublicUrl; + logger.info(`Building OIDC client (internal=${internal}, public=${pub})`); + const issuer = buildIssuer(internal, pub); + const forwardedHeaders = getAutheliaForwardedHeaders(); + issuer[custom.http_options] = (_url, options) => withAutheliaForwardedHeaders(options, forwardedHeaders); + _client = new issuer.Client(getClientMetadata()); + // Authelia derives its issuer URL from request headers. Server-side + // calls hit the in-cluster service directly (HTTP, no proxy), so + // without these forwarded headers it would derive a wrong issuer + // and reject the token exchange with `invalid_grant` / `server_error`. + _client[custom.http_options] = (_url, options) => withAutheliaForwardedHeaders(options, forwardedHeaders); + } catch (error) { + logger.error(`Error building OIDC client: ${String(error)}`); + } + return _client; + }, +}; diff --git a/website/src/utils/clientMetadata.ts b/website/src/utils/clientMetadata.ts index 1407ae6777..758e6ca76b 100644 --- a/website/src/utils/clientMetadata.ts +++ b/website/src/utils/clientMetadata.ts @@ -1,25 +1,21 @@ import { getRuntimeConfig } from '../config'; /* eslint-disable @typescript-eslint/naming-convention */ -const clientMetadata = { +const baseMetadata = { client_id: 'backend-client', - response_types: ['code', 'id_token'], - public: true, + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_basic' as const, }; /* eslint-enable @typescript-eslint/naming-convention */ +// Authelia 4.39 forbids http:// redirect URIs on public clients, so dev/CI +// (which serves the website over http) needs a confidential client with a +// real secret. The plaintext lives in the website's serverSide runtime +// config; Authelia stores the PBKDF2 hash and verifies against it. export const getClientMetadata = () => { - return { ...clientMetadata, client_secret: getClientSecret() }; // eslint-disable-line @typescript-eslint/naming-convention -}; - -const getClientSecret = () => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (import.meta.env === undefined) { - return 'dummySecret'; - } - const configDir = import.meta.env.CONFIG_DIR; - if (typeof configDir !== 'string' || configDir === '') { - return 'dummySecret'; - } - return getRuntimeConfig().backendKeycloakClientSecret; + return { + ...baseMetadata, + // eslint-disable-next-line @typescript-eslint/naming-convention + client_secret: getRuntimeConfig().serverSide.oidcClientSecret, + }; }; diff --git a/website/src/utils/getAuthUrl.ts b/website/src/utils/getAuthUrl.ts index 69f55ee2ed..b4f6ef436f 100644 --- a/website/src/utils/getAuthUrl.ts +++ b/website/src/utils/getAuthUrl.ts @@ -1,7 +1,51 @@ -import { KeycloakClientManager } from './KeycloakClientManager'; -import { realmPath } from './realmPath.ts'; +import { generators } from 'openid-client'; + +import { OidcClientManager } from './OidcClientManager'; +import { getRuntimeConfig } from '../config'; import { routes } from '../routes/routes'; +// Authelia (unlike Keycloak) requires every redirect_uri to be pre-registered +// exactly — wildcards aren't supported. We pin the OIDC callback to a single +// fixed path and encode the user's original target URL plus a PKCE code +// verifier inside the opaque `state` parameter so the callback handler can +// resume the navigation and complete the token exchange. +export const AUTH_CALLBACK_PATH = '/auth/callback'; + +const NONCE_LEN = 16; + +interface StatePayload { + n: string; // nonce (CSRF binding) + r: string; // returnTo URL + v: string; // PKCE code_verifier +} + +function encodeState(payload: StatePayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); +} + +export function decodeState(state: string | undefined): StatePayload | undefined { + if (!state) return undefined; + try { + const raw = Buffer.from(state, 'base64url').toString('utf8'); + const obj = JSON.parse(raw) as unknown; + if (typeof obj !== 'object' || obj === null) return undefined; + const payload = obj as Record; + if (typeof payload.n !== 'string' || typeof payload.r !== 'string' || typeof payload.v !== 'string') { + return undefined; + } + return { n: payload.n, r: payload.r, v: payload.v }; + } catch { + return undefined; + } +} + +function callbackUri(currentUrl: URL): string { + const u = new URL(AUTH_CALLBACK_PATH, currentUrl); + u.search = ''; + u.hash = ''; + return u.toString(); +} + export const getAuthUrl = async (redirectUrl: string) => { const logout = routes.logout(); if (redirectUrl.endsWith(logout)) { @@ -9,29 +53,31 @@ export const getAuthUrl = async (redirectUrl: string) => { } // Beware: relative url does not work with Redirect.response() - const client = await KeycloakClientManager.getClient(); + const client = await OidcClientManager.getClient(); if (client === undefined) { return `/503?service=Authentication`; } + const target = new URL(redirectUrl); + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + const nonce = generators.state().slice(0, NONCE_LEN); /* eslint-disable @typescript-eslint/naming-convention */ return client.authorizationUrl({ - redirect_uri: redirectUrl, - scope: 'openid', + redirect_uri: callbackUri(target), + scope: 'openid profile email groups offline_access', response_type: 'code', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state: encodeState({ n: nonce, r: target.toString(), v: codeVerifier }), }); /* eslint-enable @typescript-eslint/naming-convention */ }; -export const getAuthBaseUrl = async () => { - const authUrl = await getAuthUrl('/'); - const index = authUrl.indexOf('/realms'); - if (index === -1) { - return null; - } - return authUrl.substring(0, index); +// External-facing base URL of the auth provider (Authelia). Used in user-facing +// API documentation and `/loculus-info` for CLI discovery. +export const getAuthBaseUrl = (): string => { + return getRuntimeConfig().serverSide.autheliaPublicUrl; }; -export const getUrlForKeycloakAccountPage = async () => { - const baseUrl = await getAuthBaseUrl(); - return `${baseUrl}${realmPath}/account`; -}; +// Authelia exposes a self-service portal at the root of the auth URL. +export const getUrlForAccountPage = (): string => getAuthBaseUrl(); diff --git a/website/src/utils/realmPath.ts b/website/src/utils/realmPath.ts index 8c8fc07c79..cbc395e8d9 100644 --- a/website/src/utils/realmPath.ts +++ b/website/src/utils/realmPath.ts @@ -1 +1,2 @@ -export const realmPath = '/realms/loculus'; // TODO: #1339 Move realm path to config +// Authelia is its own realm — the OIDC discovery doc lives at the root. +export const realmPath = ''; diff --git a/website/vitest.setup.ts b/website/vitest.setup.ts index b705157c91..3f5174d757 100755 --- a/website/vitest.setup.ts +++ b/website/vitest.setup.ts @@ -32,7 +32,6 @@ export const testConfig = { lapisUrls: { [testOrganism]: 'http://lapis.dummy', }, - keycloakUrl: 'http://authentication.dummy', }, serverSide: { discriminator: 'server', @@ -40,10 +39,12 @@ export const testConfig = { lapisUrls: { [testOrganism]: 'http://lapis.dummy', }, - keycloakUrl: 'http://authentication.dummy', + autheliaUrl: 'http://authentication.dummy', + autheliaPublicUrl: 'http://authentication.dummy', + registrationUrl: 'http://register.dummy', + oidcClientSecret: 'dummy-client-secret', }, insecureCookies: true, - backendKeycloakClientSecret: 'dummy', } as RuntimeConfig; // Stubbing necessary since headlessui v2