From 31c416ab2ce3d93930cce02577d8eb4a67c6c6ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:11:40 -0500 Subject: [PATCH 1/7] chore(deps): Bump the "all-dependencies" group with 3 updates across multiple ecosystems (#1599) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-next.yml | 6 +- .github/workflows/e2e-tests.yml | 2 +- .github/workflows/release.yml | 6 +- .github/workflows/svelte-check.yml | 2 +- backend/go.mod | 18 +- backend/go.sum | 36 +- email-templates/package.json | 2 +- pnpm-lock.yaml | 682 +++++++++++++++-------------- 8 files changed, 396 insertions(+), 358 deletions(-) diff --git a/.github/workflows/build-next.yml b/.github/workflows/build-next.yml index dbc30af8..e9316a5b 100644 --- a/.github/workflows/build-next.yml +++ b/.github/workflows/build-next.yml @@ -33,7 +33,7 @@ jobs: run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v6.4.0 with: node-version: 24 cache: pnpm @@ -82,11 +82,11 @@ jobs: MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - name: Binary attestation - uses: actions/attest@v4 + uses: actions/attest@v4.1.1 with: subject-checksums: ./dist/checksums.txt - name: Container image attestation - uses: actions/attest@v4 + uses: actions/attest@v4.1.1 with: subject-checksums: ./dist/digests.txt diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7cde9ebd..37b6684b 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -45,7 +45,7 @@ jobs: uses: pnpm/action-setup@v5 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v6.4.0 with: node-version: 24 cache: "pnpm" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3294e9ed..27f12087 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v6.4.0 with: node-version: 24 cache: pnpm @@ -76,12 +76,12 @@ jobs: DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} - name: Binary attestation - uses: actions/attest@v4 + uses: actions/attest@v4.1.1 with: subject-checksums: ./dist/checksums.txt - name: Container image attestation - uses: actions/attest@v4 + uses: actions/attest@v4.1.1 with: subject-checksums: ./dist/digests.txt diff --git a/.github/workflows/svelte-check.yml b/.github/workflows/svelte-check.yml index 75591ce0..015be719 100644 --- a/.github/workflows/svelte-check.yml +++ b/.github/workflows/svelte-check.yml @@ -42,7 +42,7 @@ jobs: uses: pnpm/action-setup@v5 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v6.4.0 with: node-version: 24 cache: "pnpm" diff --git a/backend/go.mod b/backend/go.mod index 98f4c1a6..522c38a3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,9 +4,9 @@ go 1.26.5 require ( github.com/aws/aws-sdk-go-v2 v1.42.1 - github.com/aws/aws-sdk-go-v2/config v1.32.27 - github.com/aws/aws-sdk-go-v2/credentials v1.19.26 - github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2 + github.com/aws/aws-sdk-go-v2/config v1.32.29 + github.com/aws/aws-sdk-go-v2/credentials v1.19.28 + github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 github.com/aws/smithy-go v1.27.3 github.com/caarlos0/env/v11 v11.4.1 github.com/cenkalti/backoff/v5 v5.0.3 @@ -17,7 +17,7 @@ require ( github.com/fsnotify/fsnotify v1.10.1 github.com/gin-contrib/slog v1.2.1 github.com/gin-gonic/gin v1.12.0 - github.com/go-co-op/gocron/v2 v2.21.2 + github.com/go-co-op/gocron/v2 v2.22.0 github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-playground/validator/v10 v10.30.3 @@ -32,7 +32,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lestrrat-go/httprc/v3 v3.0.6 github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/libtnb/sqlite v1.1.2 + github.com/libtnb/sqlite v1.2.0 github.com/lmittmann/tint v1.1.3 github.com/mattn/go-isatty v0.0.22 github.com/mileusna/useragent v1.3.5 @@ -53,10 +53,10 @@ require ( go.opentelemetry.io/otel/sdk/log v0.20.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/crypto v0.53.0 - golang.org/x/image v0.43.0 + golang.org/x/crypto v0.54.0 + golang.org/x/image v0.44.0 golang.org/x/sync v0.22.0 - golang.org/x/text v0.39.0 + golang.org/x/text v0.40.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.2 gorm.io/plugin/opentelemetry v0.1.16 @@ -231,7 +231,7 @@ require ( golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect diff --git a/backend/go.sum b/backend/go.sum index ca9a4b07..1c7844e2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -22,10 +22,10 @@ github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= -github.com/aws/aws-sdk-go-v2/config v1.32.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI= -github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY= +github.com/aws/aws-sdk-go-v2/config v1.32.29 h1:BcMHHnpiWKogf+gGfpj3K1w+Sktz29XDo/cPSAPO3FU= +github.com/aws/aws-sdk-go-v2/config v1.32.29/go.mod h1:+Kbhn8Es4kPUph3F/0W7avykytc+Jh2Ld9/msv9ljV4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= @@ -42,8 +42,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I= -github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2 h1:bAY6O/TDv1HQnvylh9E247IyIKsUWUt2G965S7qX110= -github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= +github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w= github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo= @@ -161,8 +161,8 @@ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY= -github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= +github.com/go-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/z79I= +github.com/go-co-op/gocron/v2 v2.22.0/go.mod h1:hiH/U9RMhTi1BBZJmef9s3KC9QwhpBF6PFrvUKaXY9M= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= @@ -348,8 +348,8 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/libtnb/sqlite v1.1.2 h1:3g8nez8MwdEhn+oqt3bfKWnYJxk0ZGTO0kVA1WT+hjQ= -github.com/libtnb/sqlite v1.1.2/go.mod h1:2PGCWOR6HqqXuoIVMmwfJ655epvRO8sw0b9kHWuKNsQ= +github.com/libtnb/sqlite v1.2.0 h1:XsA7jsXHH2qmFkTWoy5YCKJybzHzWQ0flqDFP5Y9Yto= +github.com/libtnb/sqlite v1.2.0/go.mod h1:O6vURH5fa5IgSmXd/qLAL2zSnYFUd7xSIumsmX3BrSI= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -618,13 +618,13 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= -golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -688,8 +688,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -711,8 +711,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/email-templates/package.json b/email-templates/package.json index 80b1fd02..1adba44f 100644 --- a/email-templates/package.json +++ b/email-templates/package.json @@ -19,7 +19,7 @@ "@types/node": "^25.9.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "react-email": "6.6.6", + "react-email": "6.7.0", "tsx": "^4.22.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d6c0f64..fd0de042 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 5.2.10(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/node': specifier: ^25.9.0 - version: 25.9.4 + version: 25.9.5 '@types/react': specifier: ^19.2.14 version: 19.2.17 @@ -57,8 +57,8 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) react-email: - specifier: 6.6.6 - version: 6.6.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 6.7.0 + version: 6.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tsx: specifier: ^4.22.2 version: 4.23.0 @@ -79,13 +79,13 @@ importers: version: 2.9.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': specifier: ^1.41.1 - version: 1.41.1 + version: 1.43.0 '@simplewebauthn/browser': specifier: ^13.3.0 version: 13.3.0 '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.2(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 4.3.2(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) axios: specifier: ^1.16.1 version: 1.18.1 @@ -100,10 +100,10 @@ importers: version: 1.5.4 runed: specifier: ^0.37.1 - version: 0.37.1(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(zod@4.4.3) + version: 0.37.1(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(zod@4.4.3) sveltekit-superforms: specifier: ^2.30.1 - version: 2.30.2(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3) + version: 2.30.2(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -113,7 +113,7 @@ importers: devDependencies: '@inlang/paraglide-js': specifier: ^2.18.0 - version: 2.20.2(typescript@6.0.3) + version: 2.21.0(typescript@6.0.3) '@inlang/plugin-m-function-matcher': specifier: ^2.2.6 version: 2.2.9 @@ -125,25 +125,25 @@ importers: version: 3.12.2 '@lucide/svelte': specifier: ^1.16.0 - version: 1.23.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + version: 1.24.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)) '@sveltejs/adapter-static': specifier: ^3.0.10 - version: 3.0.10(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))) + version: 3.0.10(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))) '@sveltejs/kit': specifier: ^2.60.1 - version: 2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) '@sveltejs/vite-plugin-svelte': specifier: ^7.1.2 - version: 7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) '@types/node': specifier: ^25.9.0 - version: 25.9.4 + version: 25.9.5 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 bits-ui: specifier: ^2.18.1 - version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)) eslint: specifier: ^10.4.0 version: 10.6.0(jiti@2.7.0) @@ -152,37 +152,37 @@ importers: version: 10.1.8(eslint@10.6.0(jiti@2.7.0)) eslint-plugin-svelte: specifier: ^3.17.1 - version: 3.20.0(eslint@10.6.0(jiti@2.7.0))(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + version: 3.20.0(eslint@10.6.0(jiti@2.7.0))(svelte@5.56.4(@typescript-eslint/types@8.63.0)) formsnap: specifier: ^2.0.1 - version: 2.0.1(svelte@5.56.4(@typescript-eslint/types@8.62.1))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)) + version: 2.0.1(svelte@5.56.4(@typescript-eslint/types@8.63.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)) globals: specifier: ^17.6.0 version: 17.7.0 mode-watcher: specifier: ^1.1.0 - version: 1.1.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + version: 1.1.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)) prettier: specifier: ^3.8.3 - version: 3.9.4 + version: 3.9.5 prettier-plugin-svelte: specifier: ^3.5.2 - version: 3.5.2(prettier@3.9.4)(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + version: 3.5.2(prettier@3.9.5)(svelte@5.56.4(@typescript-eslint/types@8.63.0)) prettier-plugin-tailwindcss: specifier: ^0.8.0 - version: 0.8.0(prettier-plugin-svelte@3.5.2(prettier@3.9.4)(svelte@5.56.4(@typescript-eslint/types@8.62.1)))(prettier@3.9.4) + version: 0.8.0(prettier-plugin-svelte@3.5.2(prettier@3.9.5)(svelte@5.56.4(@typescript-eslint/types@8.63.0)))(prettier@3.9.5) shadcn-svelte: specifier: ^1.3.0 - version: 1.3.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + version: 1.4.1(svelte@5.56.4(@typescript-eslint/types@8.63.0)) svelte: specifier: ^5.55.8 - version: 5.56.4(@typescript-eslint/types@8.62.1) + version: 5.56.4(@typescript-eslint/types@8.63.0) svelte-check: specifier: ^4.4.8 - version: 4.7.1(picomatch@4.0.5)(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3) + version: 4.7.2(picomatch@4.0.5)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3) svelte-sonner: specifier: ^1.1.1 - version: 1.1.1(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + version: 1.1.1(svelte@5.56.4(@typescript-eslint/types@8.63.0)) tailwind-variants: specifier: ^3.2.2 version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.2) @@ -200,13 +200,13 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.59.4 - version: 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) vite: specifier: ^8.0.16 - version: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + version: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) vite-plugin-compression: specifier: ^0.5.1 - version: 0.5.1(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 0.5.1(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) tests: dependencies: @@ -222,7 +222,7 @@ importers: version: 0.5.8 '@types/node': specifier: ^25.9.0 - version: 25.9.4 + version: 25.9.5 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -231,7 +231,7 @@ importers: version: 6.2.3 prettier: specifier: ^3.8.3 - version: 3.9.4 + version: 3.9.5 packages: @@ -672,8 +672,8 @@ packages: cpu: [x64] os: [win32] - '@inlang/paraglide-js@2.20.2': - resolution: {integrity: sha512-V8iY3uu/vQU94gEag1bdC3glMJSp4Dg3XMwfnabZLBh1Dv0F++DvDYlMeniqv2+nHbnS/twB75AM140OmpHDEg==} + '@inlang/paraglide-js@2.21.0': + resolution: {integrity: sha512-t+OOui1i5p73zQ2Dk28yFzu6rZhh7hsDOCHjkXDmSwFFsUsS5HyUD4CvPJ+wf02qgmlCMu2z1qffJ5aWwgx1CA==} hasBin: true peerDependencies: typescript: '>=5.6' @@ -720,8 +720,8 @@ packages: '@lix-js/server-protocol-schema@0.1.1': resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} - '@lucide/svelte@1.23.0': - resolution: {integrity: sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==} + '@lucide/svelte@1.24.0': + resolution: {integrity: sha512-yXwewA7ANQ5hfaSDrvsecosWjZn5RglzeXUZRSnxeANBskpNwblOkEJTqD0ujDdNKIKL8E9eVc2U/P3ziJr7OA==} peerDependencies: svelte: ^5 @@ -854,12 +854,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/semantic-conventions@1.41.1': - resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} - '@oxc-project/types@0.138.0': - resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} '@playwright/test@1.61.1': resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} @@ -988,15 +988,15 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/render@2.0.10': - resolution: {integrity: sha512-QbgVvXeYenVi0LqkO+upcZzbyrD5Tz2wwCV+6CSzgo6ZsvwHLjf0x5Sr1C7DepcMhjYo++maJWRbcSoKMXKr1g==} + '@react-email/render@2.0.6': + resolution: {integrity: sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog==} engines: {node: '>=20.0.0'} peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/render@2.0.6': - resolution: {integrity: sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog==} + '@react-email/render@2.1.0': + resolution: {integrity: sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA==} engines: {node: '>=20.0.0'} peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -1062,97 +1062,97 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@rolldown/binding-android-arm64@1.1.4': - resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.4': - resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.4': - resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.4': - resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': - resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.4': - resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.4': - resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.4': - resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.4': - resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.4': - resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.4': - resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.4': - resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.4': - resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.1.4': - resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.4': - resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1175,8 +1175,8 @@ packages: '@simplewebauthn/browser@13.3.0': resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} - '@sinclair/typebox@0.31.28': - resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} + '@sinclair/typebox@0.31.30': + resolution: {integrity: sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==} '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -1193,13 +1193,18 @@ packages: peerDependencies: acorn: ^8.9.0 + '@sveltejs/acorn-typescript@1.0.11': + resolution: {integrity: sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==} + peerDependencies: + acorn: ^8.9.0 + '@sveltejs/adapter-static@3.0.10': resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} peerDependencies: '@sveltejs/kit': ^2.0.0 - '@sveltejs/kit@2.69.1': - resolution: {integrity: sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==} + '@sveltejs/kit@2.69.2': + resolution: {integrity: sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -1218,8 +1223,8 @@ packages: resolution: {integrity: sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==} engines: {node: '>= 18.0.0'} - '@sveltejs/vite-plugin-svelte@7.1.2': - resolution: {integrity: sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==} + '@sveltejs/vite-plugin-svelte@7.2.0': + resolution: {integrity: sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==} engines: {node: ^20.19 || ^22.12 || >=24} peerDependencies: svelte: ^5.46.4 @@ -1346,8 +1351,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@25.9.4': - resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -1385,63 +1390,63 @@ packages: '@types/json-schema': optional: true - '@typescript-eslint/eslint-plugin@8.62.1': - resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} + '@typescript-eslint/eslint-plugin@8.63.0': + resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.62.1 + '@typescript-eslint/parser': ^8.63.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.62.1': - resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==} + '@typescript-eslint/parser@8.63.0': + resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.62.1': - resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} + '@typescript-eslint/project-service@8.63.0': + resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.62.1': - resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==} + '@typescript-eslint/scope-manager@8.63.0': + resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.62.1': - resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} + '@typescript-eslint/tsconfig-utils@8.63.0': + resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.62.1': - resolution: {integrity: sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==} + '@typescript-eslint/type-utils@8.63.0': + resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.62.1': - resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} + '@typescript-eslint/types@8.63.0': + resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.62.1': - resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} + '@typescript-eslint/typescript-estree@8.63.0': + resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.62.1': - resolution: {integrity: sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==} + '@typescript-eslint/utils@8.63.0': + resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.62.1': - resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} + '@typescript-eslint/visitor-keys@8.63.0': + resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@valibot/to-json-schema@1.7.1': @@ -2019,6 +2024,9 @@ packages: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} + html5parser@3.0.0: + resolution: {integrity: sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -2034,8 +2042,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} imurmurhash@0.1.4: @@ -2133,8 +2141,8 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} - kysely@0.29.2: - resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} + kysely@0.29.3: + resolution: {integrity: sha512-VHtBdW6XB/pgoTSqraM3UAa2rYoYdNXqnNPpX+8XXP+cwYbVEFuAp3HyPt1vpNfU9l7Y2kpUrA9QDPsy8uUqOQ==} engines: {node: '>=22.0.0'} leac@0.6.0: @@ -2318,6 +2326,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -2472,6 +2485,10 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2537,8 +2554,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.9.4: - resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + prettier@3.9.5: + resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} engines: {node: '>=14'} hasBin: true @@ -2574,8 +2591,8 @@ packages: peerDependencies: react: ^19.2.7 - react-email@6.6.6: - resolution: {integrity: sha512-4eYmISIQbmEzibkl8pW+ZQHIFMwUVqZQRpTDYj6Ow7kk/gpUjtA95ggxoXji8pRq/y76tOhe7D3vNUJkZWO58g==} + react-email@6.7.0: + resolution: {integrity: sha512-lJFdxPB7A1bLgTFYSzGWxNYyP63IbJsCSe+2EOl51lSQzKCs95+dBgiJdmW+wk7+Oix7UdkWhyJMMvbdQt9HJQ==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -2601,8 +2618,8 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - rolldown@1.1.4: - resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2660,11 +2677,11 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-cookie-parser@3.1.1: - resolution: {integrity: sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} - shadcn-svelte@1.3.0: - resolution: {integrity: sha512-Pd4ICWTkTks/b2YU4c9vF2XsX1x5HFPRl5bKszS1LcnWS83x+7T4WiIvbWz8Qh9knkcGZ+SCz1+Dmhdq+AYooA==} + shadcn-svelte@1.4.1: + resolution: {integrity: sha512-lpvnuHJOMf/CRlOMo5h7HpmdpcWmqVoPyvnCcgpJWJWzVkiwmisF80mR54GIAExSTSk1g4+ere3QrSty2PCIIg==} hasBin: true peerDependencies: svelte: ^5.0.0 @@ -2691,8 +2708,8 @@ packages: socket.io-adapter@2.5.8: resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} - socket.io-parser@4.2.6: - resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} engines: {node: '>=10.0.0'} socket.io@4.8.3: @@ -2750,8 +2767,8 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - svelte-check@4.7.1: - resolution: {integrity: sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==} + svelte-check@4.7.2: + resolution: {integrity: sha512-GoS4XJdGswlq0rIT1vtFLzJY1bvHtY37McY9H9Gkm1Ggw/ICdZYn8J/Z8Yi0BEL0i3R4+jtaWVePjyppMlij/A==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: @@ -2823,6 +2840,9 @@ packages: tailwindcss@4.3.2: resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -2888,8 +2908,8 @@ packages: typebox@1.3.3: resolution: {integrity: sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==} - typescript-eslint@8.62.1: - resolution: {integrity: sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==} + typescript-eslint@8.63.0: + resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2949,8 +2969,8 @@ packages: peerDependencies: vite: '>=2.0.0' - vite@8.1.3: - resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + vite@8.1.4: + resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3022,8 +3042,8 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3398,7 +3418,7 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inlang/paraglide-js@2.20.2(typescript@6.0.3)': + '@inlang/paraglide-js@2.21.0(typescript@6.0.3)': dependencies: '@inlang/recommend-sherlock': 0.2.1 '@inlang/sdk': 2.10.2 @@ -3429,9 +3449,9 @@ snapshots: '@inlang/sdk@2.10.2': dependencies: '@lix-js/sdk': 0.4.10 - '@sinclair/typebox': 0.31.28 - kysely: 0.29.2 - sqlite-wasm-kysely: 0.3.0(kysely@0.29.2) + '@sinclair/typebox': 0.31.30 + kysely: 0.29.3 + sqlite-wasm-kysely: 0.3.0(kysely@0.29.3) uuid: 14.0.1 transitivePeerDependencies: - babel-plugin-macros @@ -3465,17 +3485,17 @@ snapshots: dedent: 1.5.1 human-id: 4.2.0 js-sha256: 0.11.1 - kysely: 0.29.2 - sqlite-wasm-kysely: 0.3.0(kysely@0.29.2) + kysely: 0.29.3 + sqlite-wasm-kysely: 0.3.0(kysely@0.29.3) uuid: 14.0.1 transitivePeerDependencies: - babel-plugin-macros '@lix-js/server-protocol-schema@0.1.1': {} - '@lucide/svelte@1.23.0(svelte@5.56.4(@typescript-eslint/types@8.62.1))': + '@lucide/svelte@1.24.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))': dependencies: - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: @@ -3519,7 +3539,7 @@ snapshots: '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/exporter-trace-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': dependencies: @@ -3550,7 +3570,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)': dependencies: @@ -3558,7 +3578,7 @@ snapshots: '@opentelemetry/api-logs': 0.220.0 '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)': dependencies: @@ -3572,7 +3592,7 @@ snapshots: '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1)': dependencies: @@ -3585,11 +3605,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/semantic-conventions@1.41.1': {} + '@opentelemetry/semantic-conventions@1.43.0': {} - '@oxc-project/types@0.138.0': {} + '@oxc-project/types@0.139.0': {} '@playwright/test@1.61.1': dependencies: @@ -3702,17 +3722,19 @@ snapshots: dependencies: react: 19.2.7 - '@react-email/render@2.0.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - html-to-text: 9.0.5 - prettier: 3.9.4 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - '@react-email/render@2.0.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: html-to-text: 9.0.5 - prettier: 3.9.4 + prettier: 3.9.5 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@react-email/render@2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + entities: 4.5.0 + html-to-text: 9.0.5 + html5parser: 3.0.0 + prettier: 3.9.5 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -3728,7 +3750,7 @@ snapshots: dependencies: '@react-email/text': 0.1.6(react@19.2.7) react: 19.2.7 - tailwindcss: 4.3.2 + tailwindcss: 4.3.3 optionalDependencies: '@react-email/body': 0.3.0(react@19.2.7) '@react-email/button': 0.2.1(react@19.2.7) @@ -3745,53 +3767,53 @@ snapshots: dependencies: react: 19.2.7 - '@rolldown/binding-android-arm64@1.1.4': + '@rolldown/binding-android-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-arm64@1.1.4': + '@rolldown/binding-darwin-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-x64@1.1.4': + '@rolldown/binding-darwin-x64@1.1.5': optional: true - '@rolldown/binding-freebsd-x64@1.1.4': + '@rolldown/binding-freebsd-x64@1.1.5': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.4': + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.4': + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.4': + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.4': + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.4': + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-musl@1.1.4': + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true - '@rolldown/binding-openharmony-arm64@1.1.4': + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true - '@rolldown/binding-wasm32-wasi@1.1.4': + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.4': + '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.4': + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -3814,7 +3836,7 @@ snapshots: '@simplewebauthn/browser@13.3.0': {} - '@sinclair/typebox@0.31.28': {} + '@sinclair/typebox@0.31.30': {} '@socket.io/component-emitter@3.1.2': {} @@ -3826,15 +3848,19 @@ snapshots: dependencies: acorn: 8.17.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))': + '@sveltejs/acorn-typescript@1.0.11(acorn@8.17.0)': dependencies: - '@sveltejs/kit': 2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + acorn: 8.17.0 - '@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))': + dependencies: + '@sveltejs/kit': 2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + + '@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': dependencies: '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.10(acorn@8.17.0) - '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + '@sveltejs/acorn-typescript': 1.0.11(acorn@8.17.0) + '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) '@types/cookie': 0.6.0 acorn: 8.17.0 cookie: 0.7.2 @@ -3843,24 +3869,24 @@ snapshots: kleur: 4.1.5 magic-string: 0.30.21 mrmime: 2.0.1 - set-cookie-parser: 3.1.1 + set-cookie-parser: 3.1.2 sirv: 3.0.2 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) - vite: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) + vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) optionalDependencies: '@opentelemetry/api': 1.9.1 typescript: 6.0.3 '@sveltejs/load-config@0.2.0': {} - '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.3 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) - vite: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) + vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) '@swc/helpers@0.5.15': dependencies: @@ -3931,12 +3957,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - '@tailwindcss/vite@4.3.2(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 tailwindcss: 4.3.2 - vite: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) '@tybys/wasm-util@0.10.3': dependencies: @@ -3945,13 +3971,13 @@ snapshots: '@types/adm-zip@0.5.8': dependencies: - '@types/node': 25.9.4 + '@types/node': 25.9.5 '@types/cookie@0.6.0': {} '@types/cors@2.8.19': dependencies: - '@types/node': 25.9.4 + '@types/node': 25.9.5 '@types/esrecurse@4.3.1': {} @@ -3959,13 +3985,13 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@25.9.4': + '@types/node@25.9.5': dependencies: undici-types: 7.24.6 '@types/qrcode@1.5.6': dependencies: - '@types/node': 25.9.4 + '@types/node': 25.9.5 '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: @@ -3982,7 +4008,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.4 + '@types/node': 25.9.5 '@typeschema/class-validator@0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.4)': dependencies: @@ -3998,57 +4024,57 @@ snapshots: '@types/json-schema': 7.0.15 optional: true - '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.62.1 - '@typescript-eslint/type-utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.62.1 + '@typescript-eslint/parser': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/type-utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.63.0 eslint: 10.6.0(jiti@2.7.0) - ignore: 7.0.5 + ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.62.1 - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.62.1 + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.63.0 debug: 4.4.3 eslint: 10.6.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.62.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.63.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) - '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3) + '@typescript-eslint/types': 8.63.0 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.62.1': + '@typescript-eslint/scope-manager@8.63.0': dependencies: - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/visitor-keys': 8.62.1 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 - '@typescript-eslint/tsconfig-utils@8.62.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.63.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 10.6.0(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -4056,14 +4082,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.62.1': {} + '@typescript-eslint/types@8.63.0': {} - '@typescript-eslint/typescript-estree@8.62.1(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.63.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.62.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/visitor-keys': 8.62.1 + '@typescript-eslint/project-service': 8.63.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.5 @@ -4073,20 +4099,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.62.1 - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) eslint: 10.6.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.62.1': + '@typescript-eslint/visitor-keys@8.63.0': dependencies: - '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/types': 8.63.0 eslint-visitor-keys: 5.0.1 '@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@6.0.3))': @@ -4193,15 +4219,15 @@ snapshots: baseline-browser-mapping@2.10.42: {} - bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/dom': 1.7.6 '@internationalized/date': 3.12.2 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1)) - svelte: 5.56.4(@typescript-eslint/types@8.62.1) - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + runed: 0.35.1(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)) tabbable: 6.5.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -4386,7 +4412,7 @@ snapshots: engine.io@6.6.9: dependencies: '@types/cors': 2.8.19 - '@types/node': 25.9.4 + '@types/node': 25.9.5 '@types/ws': 8.18.1 accepts: 1.3.8 base64id: 2.0.0 @@ -4394,7 +4420,7 @@ snapshots: cors: 2.8.6 debug: 4.4.3 engine.io-parser: 5.2.3 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - supports-color @@ -4459,7 +4485,7 @@ snapshots: dependencies: eslint: 10.6.0(jiti@2.7.0) - eslint-plugin-svelte@3.20.0(eslint@10.6.0(jiti@2.7.0))(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + eslint-plugin-svelte@3.20.0(eslint@10.6.0(jiti@2.7.0))(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -4471,9 +4497,9 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.16) postcss-safe-parser: 7.0.1(postcss@8.5.16) semver: 7.8.5 - svelte-eslint-parser: 1.8.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + svelte-eslint-parser: 1.8.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)) optionalDependencies: - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) transitivePeerDependencies: - ts-node @@ -4552,11 +4578,11 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.2.13(@typescript-eslint/types@8.62.1): + esrap@2.2.13(@typescript-eslint/types@8.63.0): dependencies: '@jridgewell/sourcemap-codec': 1.5.5 optionalDependencies: - '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/types': 8.63.0 esrecurse@4.3.0: dependencies: @@ -4616,11 +4642,11 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 - formsnap@2.0.1(svelte@5.56.4(@typescript-eslint/types@8.62.1))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)): + formsnap@2.0.1(svelte@5.56.4(@typescript-eslint/types@8.63.0))(sveltekit-superforms@2.30.2(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)): dependencies: - svelte: 5.56.4(@typescript-eslint/types@8.62.1) - svelte-toolbelt: 0.5.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)) - sveltekit-superforms: 2.30.2(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) + svelte-toolbelt: 0.5.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)) + sveltekit-superforms: 2.30.2(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3) fs-extra@10.1.0: dependencies: @@ -4696,6 +4722,8 @@ snapshots: htmlparser2: 8.0.2 selderee: 0.11.0 + html5parser@3.0.0: {} + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -4714,7 +4742,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.6: {} imurmurhash@0.1.4: {} @@ -4791,7 +4819,7 @@ snapshots: known-css-properties@0.37.0: {} - kysely@0.29.2: {} + kysely@0.29.3: {} leac@0.6.0: {} @@ -4907,11 +4935,11 @@ snapshots: minipass@7.1.3: {} - mode-watcher@1.1.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + mode-watcher@1.1.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: - runed: 0.25.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)) - svelte: 5.56.4(@typescript-eslint/types@8.62.1) - svelte-toolbelt: 0.7.1(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + runed: 0.25.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) + svelte-toolbelt: 0.7.1(svelte@5.56.4(@typescript-eslint/types@8.63.0)) mri@1.2.0: {} @@ -4921,6 +4949,8 @@ snapshots: nanoid@3.3.15: {} + nanoid@3.3.16: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -4931,7 +4961,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.42 caniuse-lite: 1.0.30001800 - postcss: 8.5.16 + postcss: 8.5.19 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(react@19.2.7) @@ -5055,20 +5085,26 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.19: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} - prettier-plugin-svelte@3.5.2(prettier@3.9.4)(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + prettier-plugin-svelte@3.5.2(prettier@3.9.5)(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: - prettier: 3.9.4 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + prettier: 3.9.5 + svelte: 5.56.4(@typescript-eslint/types@8.63.0) - prettier-plugin-tailwindcss@0.8.0(prettier-plugin-svelte@3.5.2(prettier@3.9.4)(svelte@5.56.4(@typescript-eslint/types@8.62.1)))(prettier@3.9.4): + prettier-plugin-tailwindcss@0.8.0(prettier-plugin-svelte@3.5.2(prettier@3.9.5)(svelte@5.56.4(@typescript-eslint/types@8.63.0)))(prettier@3.9.5): dependencies: - prettier: 3.9.4 + prettier: 3.9.5 optionalDependencies: - prettier-plugin-svelte: 3.5.2(prettier@3.9.4)(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + prettier-plugin-svelte: 3.5.2(prettier@3.9.5)(svelte@5.56.4(@typescript-eslint/types@8.63.0)) - prettier@3.9.4: {} + prettier@3.9.5: {} prismjs@1.30.0: {} @@ -5098,11 +5134,11 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 - react-email@6.6.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + react-email@6.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@babel/parser': 7.27.0 '@babel/traverse': 7.27.0 - '@react-email/render': 2.0.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-email/render': 2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) chokidar: 4.0.3 commander: 13.1.0 conf: 15.1.0 @@ -5122,7 +5158,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) socket.io: 4.8.3 - tailwindcss: 4.3.2 + tailwindcss: 4.3.3 tsconfig-paths: 4.2.0 transitivePeerDependencies: - bufferutil @@ -5139,59 +5175,59 @@ snapshots: require-main-filename@2.0.0: {} - rolldown@1.1.4: + rolldown@1.1.5: dependencies: - '@oxc-project/types': 0.138.0 + '@oxc-project/types': 0.139.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.4 - '@rolldown/binding-darwin-arm64': 1.1.4 - '@rolldown/binding-darwin-x64': 1.1.4 - '@rolldown/binding-freebsd-x64': 1.1.4 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 - '@rolldown/binding-linux-arm64-gnu': 1.1.4 - '@rolldown/binding-linux-arm64-musl': 1.1.4 - '@rolldown/binding-linux-ppc64-gnu': 1.1.4 - '@rolldown/binding-linux-s390x-gnu': 1.1.4 - '@rolldown/binding-linux-x64-gnu': 1.1.4 - '@rolldown/binding-linux-x64-musl': 1.1.4 - '@rolldown/binding-openharmony-arm64': 1.1.4 - '@rolldown/binding-wasm32-wasi': 1.1.4 - '@rolldown/binding-win32-arm64-msvc': 1.1.4 - '@rolldown/binding-win32-x64-msvc': 1.1.4 + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 - runed@0.23.4(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + runed@0.23.4(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: esm-env: 1.2.2 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) - runed@0.25.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + runed@0.25.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: esm-env: 1.2.2 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) - runed@0.28.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + runed@0.28.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: esm-env: 1.2.2 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) - runed@0.35.1(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + runed@0.35.1(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) optionalDependencies: - '@sveltejs/kit': 2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + '@sveltejs/kit': 2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) - runed@0.37.1(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(zod@4.4.3): + runed@0.37.1(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(zod@4.4.3): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) optionalDependencies: - '@sveltejs/kit': 2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + '@sveltejs/kit': 2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) zod: 4.4.3 sade@1.8.1: @@ -5208,14 +5244,14 @@ snapshots: set-blocking@2.0.0: {} - set-cookie-parser@3.1.1: {} + set-cookie-parser@3.1.2: {} - shadcn-svelte@1.3.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + shadcn-svelte@1.4.1(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: commander: 14.0.3 node-fetch-native: 1.6.7 - postcss: 8.5.16 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + postcss: 8.5.19 + svelte: 5.56.4(@typescript-eslint/types@8.63.0) tailwind-merge: 3.6.0 sharp@0.34.5: @@ -5267,13 +5303,13 @@ snapshots: socket.io-adapter@2.5.8: dependencies: debug: 4.4.3 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - socket.io-parser@4.2.6: + socket.io-parser@4.2.7: dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3 @@ -5288,7 +5324,7 @@ snapshots: debug: 4.4.3 engine.io: 6.6.9 socket.io-adapter: 2.5.8 - socket.io-parser: 4.2.6 + socket.io-parser: 4.2.7 transitivePeerDependencies: - bufferutil - supports-color @@ -5296,10 +5332,10 @@ snapshots: source-map-js@1.2.1: {} - sqlite-wasm-kysely@0.3.0(kysely@0.29.2): + sqlite-wasm-kysely@0.3.0(kysely@0.29.3): dependencies: '@sqlite.org/sqlite-wasm': 3.48.0-build4 - kysely: 0.29.2 + kysely: 0.29.3 string-width@4.2.3: dependencies: @@ -5335,7 +5371,7 @@ snapshots: dependencies: has-flag: 4.0.0 - svelte-check@4.7.1(picomatch@4.0.5)(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3): + svelte-check@4.7.2(picomatch@4.0.5)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 '@sveltejs/load-config': 0.2.0 @@ -5343,12 +5379,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) typescript: 6.0.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.8.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + svelte-eslint-parser@1.8.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -5358,36 +5394,36 @@ snapshots: postcss-selector-parser: 7.1.4 semver: 7.8.5 optionalDependencies: - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) - svelte-sonner@1.1.1(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + svelte-sonner@1.1.1(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: - runed: 0.28.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)) - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + runed: 0.28.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) - svelte-toolbelt@0.10.6(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + runed: 0.35.1(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)) style-to-object: 1.0.14 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) transitivePeerDependencies: - '@sveltejs/kit' - svelte-toolbelt@0.5.0(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + svelte-toolbelt@0.5.0(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: clsx: 2.1.1 style-to-object: 1.0.14 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) - svelte-toolbelt@0.7.1(svelte@5.56.4(@typescript-eslint/types@8.62.1)): + svelte-toolbelt@0.7.1(svelte@5.56.4(@typescript-eslint/types@8.63.0)): dependencies: clsx: 2.1.1 - runed: 0.23.4(svelte@5.56.4(@typescript-eslint/types@8.62.1)) + runed: 0.23.4(svelte@5.56.4(@typescript-eslint/types@8.63.0)) style-to-object: 1.0.14 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) - svelte@5.56.4(@typescript-eslint/types@8.62.1): + svelte@5.56.4(@typescript-eslint/types@8.63.0): dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -5400,7 +5436,7 @@ snapshots: clsx: 2.1.1 devalue: 5.8.1 esm-env: 1.2.2 - esrap: 2.2.13(@typescript-eslint/types@8.62.1) + esrap: 2.2.13(@typescript-eslint/types@8.63.0) is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 @@ -5408,12 +5444,12 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' - sveltekit-superforms@2.30.2(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3): + sveltekit-superforms@2.30.2(@sveltejs/kit@2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@types/json-schema@7.0.15)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3): dependencies: - '@sveltejs/kit': 2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.1))(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.62.1))(typescript@6.0.3)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + '@sveltejs/kit': 2.69.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(typescript@6.0.3)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) devalue: 5.8.1 memoize-weak: 1.0.2 - svelte: 5.56.4(@typescript-eslint/types@8.62.1) + svelte: 5.56.4(@typescript-eslint/types@8.63.0) ts-deepmerge: 8.0.0 optionalDependencies: '@exodus/schemasafe': 1.3.0 @@ -5450,6 +5486,8 @@ snapshots: tailwindcss@4.3.2: {} + tailwindcss@4.3.3: {} + tapable@2.3.3: {} tiny-case@1.0.3: @@ -5506,12 +5544,12 @@ snapshots: typebox@1.3.3: optional: true - typescript-eslint@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.6.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -5552,33 +5590,33 @@ snapshots: vary@1.1.2: {} - vite-plugin-compression@0.5.1(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)): + vite-plugin-compression@0.5.1(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: chalk: 4.1.2 debug: 4.4.3 fs-extra: 10.1.0 - vite: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0): + vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 - postcss: 8.5.16 - rolldown: 1.1.4 + postcss: 8.5.19 + rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.4 + '@types/node': 25.9.5 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.0 yaml: 2.9.0 - vitefu@1.1.3(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)): optionalDependencies: - vite: 8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) webpack-virtual-modules@0.6.2: {} @@ -5598,7 +5636,7 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - ws@8.21.0: {} + ws@8.21.1: {} y18n@4.0.3: {} From b254d969a5e974945680ef190e10ffadf4bab5a6 Mon Sep 17 00:00:00 2001 From: "Alessandro (Ale) Segala" <43508+ItalyPaleAle@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:15:00 -0700 Subject: [PATCH 2/7] fix: sync group membership for users added at creation time (#1600) --- backend/internal/service/user_service.go | 47 +++++++- backend/internal/service/user_service_test.go | 104 ++++++++++++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 backend/internal/service/user_service_test.go diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 07be9c67..0f974e0d 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -301,15 +301,26 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea return model.User{}, err } + // Bump the UpdatedAt timestamp of the groups the new user was added to + // This is necessary for SCIM to work with the newly-created user, or groups may not be synced via SCIM + if len(userGroups) > 0 { + err = s.touchUserGroups(ctx, tx, groupIDs(userGroups)) + if err != nil { + return model.User{}, err + } + } + // Apply default groups and claims for new non-LDAP users if !isLdapSync { if len(input.UserGroupIds) == 0 { - if err := s.applyDefaultGroups(ctx, &user, tx); err != nil { + err = s.applyDefaultGroups(ctx, &user, tx) + if err != nil { return model.User{}, err } } - if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil { + err = s.applyDefaultCustomClaims(ctx, &user, tx) + if err != nil { return model.User{}, err } } @@ -348,11 +359,43 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, if err != nil { return fmt.Errorf("failed to associate default user groups: %w", err) } + + // Bump the groups' UpdatedAt so the SCIM sync picks up the new + // membership (see touchUserGroups for details). + touchIDs := make([]string, len(groups)) + for i := range groups { + touchIDs[i] = groups[i].ID + } + if err := s.touchUserGroups(ctx, tx, touchIDs); err != nil { + return fmt.Errorf("failed to update default user groups timestamp: %w", err) + } } } return nil } +// touchUserGroups updates the UpdatedAt timestamp of the given user groups. +// +// Group membership is stored in the user_groups_users join table, so adding or +// removing a member does not modify the group row itself. The SCIM sync only +// re-pushes a group to the provider when its UpdatedAt is not older than the +// remote resource's last-modified time, so any code path that changes a group's +// membership must bump this timestamp explicitly. Otherwise the membership +// change is never synced to the SCIM provider. +func (s *UserService) touchUserGroups(ctx context.Context, tx *gorm.DB, ids []string) error { + if len(ids) == 0 { + return nil + } + + now := datatype.DateTime(time.Now()) + return tx. + WithContext(ctx). + Model(&model.UserGroup{}). + Where("id IN ?", ids). + Update("updated_at", now). + Error +} + func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error { config := s.appConfigService.GetDbConfig() diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go new file mode 100644 index 00000000..3799b36f --- /dev/null +++ b/backend/internal/service/user_service_test.go @@ -0,0 +1,104 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + "github.com/pocket-id/pocket-id/backend/internal/storage" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +func newTestUserService(t *testing.T, appConfig *AppConfigService) (*UserService, *UserGroupService) { + t.Helper() + + db := testutils.NewDatabaseForTest(t) + + fileStorage, err := storage.NewDatabaseStorage(db) + require.NoError(t, err) + + userService := NewUserService( + db, + nil, + nil, + nil, + appConfig, + NewCustomClaimService(db), + NewAppImagesService(map[string]string{}, fileStorage), + nil, + fileStorage, + ) + groupService := NewUserGroupService(db, appConfig, nil) + + return userService, groupService +} + +func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) { + appConfig := NewTestAppConfigService(&model.AppConfig{ + RequireUserEmail: model.AppConfigVariable{Value: "false"}, + }) + userService, groupService := newTestUserService(t, appConfig) + + group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{ + Name: "members", + FriendlyName: "Members", + }) + require.NoError(t, err) + require.Nil(t, group.UpdatedAt, "a freshly created group has no UpdatedAt yet") + + // Create a user that is a member of the group + // This mirrors signing up via an invite link that adds the user to a group + email := "member@example.com" + _, err = userService.CreateUser(t.Context(), dto.UserCreateDto{ + Username: "member", + Email: &email, + FirstName: "Group", + LastName: "Member", + UserGroupIds: []string{group.ID}, + }) + require.NoError(t, err) + + // The group's UpdatedAt must now be set + updated, err := groupService.Get(t.Context(), group.ID) + require.NoError(t, err) + require.NotNil(t, updated.UpdatedAt, "creating a group member must bump the group's UpdatedAt") + require.False(t, updated.LastModified().Before(updated.CreatedAt.ToTime()), "group LastModified must not predate its CreatedAt after a membership change") + require.Len(t, updated.Users, 1, "the user should be a member of the group") +} + +func TestCreateUserBumpsDefaultGroupUpdatedAt(t *testing.T) { + appConfig := NewTestAppConfigService(&model.AppConfig{ + RequireUserEmail: model.AppConfigVariable{Value: "false"}, + }) + userService, groupService := newTestUserService(t, appConfig) + + group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{ + Name: "default", + FriendlyName: "Default", + }) + require.NoError(t, err) + require.Nil(t, group.UpdatedAt) + + // Configure the group as a default signup group + defaultGroups, err := json.Marshal([]string{group.ID}) + require.NoError(t, err) + appConfig.dbConfig.Load().SignupDefaultUserGroupIDs.Value = string(defaultGroups) + + // Create a user without explicit group IDs, so the default groups apply + email := "default@example.com" + _, err = userService.CreateUser(t.Context(), dto.UserCreateDto{ + Username: "defaultmember", + Email: &email, + FirstName: "Default", + LastName: "Member", + }) + require.NoError(t, err) + + updated, err := groupService.Get(t.Context(), group.ID) + require.NoError(t, err) + require.NotNil(t, updated.UpdatedAt, "adding a default group member must bump the group's UpdatedAt") + require.Len(t, updated.Users, 1) +} From 921095f0a224c4f412a0e22ec8c661eee0145740 Mon Sep 17 00:00:00 2001 From: "Alessandro (Ale) Segala" <43508+ItalyPaleAle@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:32:27 -0700 Subject: [PATCH 3/7] fix: relax Francis DB usage while HA is still WIP (#1602) Co-authored-by: Claude --- backend/internal/bootstrap/actors_bootstrap.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/internal/bootstrap/actors_bootstrap.go b/backend/internal/bootstrap/actors_bootstrap.go index 7a697d45..d497364b 100644 --- a/backend/internal/bootstrap/actors_bootstrap.go +++ b/backend/internal/bootstrap/actors_bootstrap.go @@ -49,6 +49,12 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer local.WithLogger(log.With("scope", "actor-host")), local.WithRuntimePSKs(psk), local.WithShutdownGracePeriod(10 * time.Second), + // TODO: Tweak these values once Pocket ID fully supports horizontal scaling. + // The relaxed intervals are appropriate for a single active host, but should be + // tuned for lower latency and better distribution across a multi-host cluster. + local.WithHostHealthCheckDeadline(90 * time.Second), + local.WithAlarmsPollInterval(5 * time.Minute), + local.WithAlarmsFetchAheadInterval(5 * time.Minute), } // Add the database connection From 472fff33ea513e14b0ced659f817ab16090fe13f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:38:36 +0200 Subject: [PATCH 4/7] chore: update AAGUIDs (#1608) Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com> --- backend/resources/aaguids.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/resources/aaguids.json b/backend/resources/aaguids.json index a2d0f4cf..795d4b0e 100644 --- a/backend/resources/aaguids.json +++ b/backend/resources/aaguids.json @@ -1 +1 @@ -{"fcb1bcb4-f370-078c-6993-bc24d0ae3fbe":"Ledger Nano X FIDO2 Authenticator","6e8d1eae-8d40-4c25-bcf8-4633959afc71":"Veridium iOS SDK","e8b7f4a2-c3d5-e6f7-890a-b1c2d3e4f567":"Sherlocked","9eb7eabc-9db5-49a1-b6c3-555a802093f4":"YubiKey 5 Series with NFC KVZR57","4d41190c-7beb-4a84-8018-adf265a6352d":"Thales IDPrime FIDO Bio","2772ce93-eb4b-4090-8b73-330f48477d73":"Security Key NFC by Yubico - Enterprise Edition Preview","6dae43be-af9c-417b-8b9f-1b611168ec60":"Dapple Authenticator from Dapple Security Inc.","5626bed4-e756-430b-a7ff-ca78c8b12738":"VALMIDO PRO FIDO","260e3021-482d-442d-838c-7edfbe153b7e":"Feitian ePass FIDO2-NFC Plus Authenticator","95e4d58c-056e-4a65-866d-f5a69659e880":"TruU Windows Authenticator","90636e1f-ef82-43bf-bdcf-5255f139d12f":"YubiKey Bio Series - Multi-protocol Edition","9c835346-796b-4c27-8898-d6032f515cc5":"Cryptnox FIDO2","c3f47802-de73-4dfc-ba22-671fe3304f90":"eToken Fusion NFC PIV Enterprise","0d9b2e56-566b-c393-2940-f821b7f15d6d":"Excelsecu eSecu FIDO2 Pro Security Key","2bff89f2-323a-48fc-b7c8-9ff7fe87c07e":"Feitian BioPass FIDO2 Pro (Enterprise Profile)","c5ef55ff-ad9a-4b9f-b580-adebafe026d0":"YubiKey 5 Series with Lightning","2194b428-9397-4046-8f39-007a1605a482":"IDPrime 931 Fido","39a5647e-1853-446c-a1f6-a79bae9f5bc7":"IDmelon","664d9f67-84a2-412a-9ff7-b4f7d8ee6d05":"OpenSK authenticator","3789da91-f943-46bc-95c3-50ea2012f03a":"NEOWAVE Winkeo FIDO2","fa2b99dc-9e39-4257-8f92-4a30d23c4118":"YubiKey 5 Series with NFC","341e4da9-3c2e-8103-5a9f-aad887135200":"Ledger Nano S FIDO2 Authenticator","69700f79-d1fb-472e-bd9b-a3a3b9a9eda0":"Pone Biometrics OFFPAD Authenticator","8da0e4dc-164b-454e-972e-88f362b23d59":"CardOS FIDO2 Token","89b19028-256b-4025-8872-255358d950e4":"Sentry Enterprises CTAP2 Authenticator","4e768f2c-5fab-48b3-b300-220eb487752b":"Hideez Key 4 FIDO2 SDK","47ab2fb4-66ac-4184-9ae1-86be814012d5":"Security Key NFC by Yubico - Enterprise Edition","931327dd-c89b-406c-a81e-ed7058ef36c6":"Swissbit iShield Key FIDO2","f8d5c4e9-e539-4c06-8662-ec2a4155a555":"StarSign Key Fob","b7d3f68e-88a6-471e-9ecf-2df26d041ede":"Security Key NFC by Yubico","8d1b1fcb-3c76-49a9-9129-5515b346aa02":"IDEMIA ID-ONE Card","30b5035e-d297-4ff7-020b-addc96ba6a98":"OneSpan DIGIPASS FX7","454e5346-4944-4ffd-6c93-8e9267193e9a":"Ensurity ThinC","e1a96183-5016-4f24-b55b-e3ae23614cc6":"ATKey.Pro CTAP2.0","9ff4cc65-6154-4fff-ba09-9e2af7882ad2":"Security Key NFC by Yubico - Enterprise Edition (Enterprise Profile)","4599062e-6926-4fe7-9566-9e8fb1aedaa0":"YubiKey 5 Series (Enterprise Profile)","9d3df6ba-282f-11ed-a261-0242ac120002":"Arculus FIDO2/U2F Key Card","fbefdf68-fe86-0106-213e-4d5fa24cbe2e":"Excelsecu eSecu FIDO2 NFC Security Key","62e54e98-c209-4df3-b692-de71bb6a8528":"YubiKey 5 FIPS Series with NFC Preview","ab32f0c6-2239-afbb-c470-d2ef4e254db7":"TOKEN2 FIDO2 Security Key","ce6bf97f-9f69-4ba7-9032-97adc6ca5cf1":"YubiKey 5 FIPS Series with NFC (RC Preview)","ad08c78a-4e41-49b9-86a2-ac15b06899e2":"YubiKey Bio Series - FIDO Edition (Enterprise Profile)","930b0c03-ef46-4ac4-935c-538dccd1fcdb":"Chipwon Clife Key","7787a482-13e8-4784-8a06-c7ed49a7aaf4":"Swissbit iShield Key 2","72c6b72d-8512-4c66-8359-9d3d10d9222f":"Security Key NFC by Yubico - Enterprise Edition (Enterprise Profile)","99ed6c29-4573-4847-816d-78ad8f1c75ef":"VeroCard FIDO2 Authenticator","973446ca-e21c-9a9b-99f5-9b985a67af0f":"ACS FIDO Authenticator Card","238ab2f5-b57f-4917-b3c6-3d3c6c0c350f":"FEITIAN FT-JCOS BioCard","74820b05-a6c9-40f9-8fb0-9f86aca93998":"SafeNet eToken Fusion","1105e4ed-af1d-02ff-ffff-ffffffffffff":"Egomet FIDO2 Authenticator for Android","08987058-cadc-4b81-b6e1-30de50dcbe96":"Windows Hello","a4e9fc6d-4cbe-4758-b8ba-37598bb5bbaa":"Security Key NFC by Yubico","0acf3011-bc60-f375-fb53-6f05f43154e0":"Nymi FIDO2 Authenticator","d91c5288-0ef0-49b7-b8ae-21ca0aa6b3f3":"KEY-ID FIDO2 Authenticator","8eec9bf9-486c-46da-9a67-1fbb4f66b9ed":"HID Crescendo 4000 FIPS","4c50ff10-1057-4fc6-b8ed-43a529530c3c":"ImproveID Authenticator","c611b55c-77b2-4527-8082-590e931b2f08":"GoTrust Idem Key ","ee041bce-25e5-4cdb-8f86-897fd6418464":"Feitian ePass FIDO2-NFC Authenticator","2588ae83-5a3b-4536-b8de-5e540200d191":"Dapple Authenticator from Dapple Security Inc.","4b89f401-464e-4745-a520-486ddfc5d80e":"IIST FIDO2 Authenticator","2cd2f727-f6ca-44da-8f48-5c2e5da000a2":"Nitrokey 3 AM","10c70715-2a9a-4de1-b0aa-3cff6d496d39":"eToken Fusion NFC FIPS","efb96b10-a9ee-4b6c-a4a9-d32125ccd4a4":"Safenet eToken FIDO","24083bcb-3034-4867-99de-a3b52e1d426a":"Enterprise Security Key Series with NFC (Consumer Profile)","4b3f8944-d4f2-4d21-bb19-764a986ec160":"KeyXentic FIDO2 Secp256R1 FIDO2 CTAP2 Authenticator","4c0cf95d-2f40-43b5-ba42-4c83a11c04ba":"Feitian BioPass FIDO2 Pro Authenticator","5343502d-5343-5343-6172-644649444f32":"ESS Smart Card Inc. Authenticator","69e7c36f-f2f6-9e0d-07a6-bcc243262e6b":"OneKey FIDO2 Authenticator","524de2de-982f-49b4-a769-2b5e3b73ad79":"YubiKey 5 Series (Enterprise Profile)","09591fc6-9811-48f7-8f57-b9f23df6413f":"Pone Biometrics OFFPAD Authenticator","912435d9-4a88-42f3-972d-1244b0d51420":"SI0X FIDO CL WRIST v1.0","7e3f3d30-3557-4442-bdae-139312178b39":"RSA DS100","73bb0cd4-e502-49b8-9c6f-b59445bf720b":"YubiKey 5 FIPS Series","39589099-9a75-49fc-afaa-801ca211c62a":"Feitian ePass FIDO-NFC (Enterprise Profile) (CTAP2.1, CTAP2.0, U2F)","dc5e949d-f939-43b3-9877-a85c7186b753":"YubiKey Bio Multi-protocol Edition (Enterprise Profile)","149a2021-8ef6-4133-96b8-81f8d5b7f1f5":"Security Key by Yubico with NFC","5df66f62-5b47-43d3-aa1d-a6e31c8dbeb5":"Securitag Assembly Group FIDO Authenticator NFC","9a272558-5cfa-4424-be37-65509677b77d":"SECORA ID Key S USB by Infineon Consumer Edition","09619fbf-d75e-4a62-be1d-fe4d240864ae":"VeriMark(TM) Guard 2.1 Fingerprint Security Key","55fd881f-40ba-4eaf-8435-42c5fed08b76":"SECORA ID V2 by Infineon Consumer Edition","50cbf15a-238c-4457-8f16-812c43bf3c49":"Ensurity AUTH TouchPro","ee7fa1e0-9539-432f-bd43-9c2fc6d4f311":"VeriMark NFC+ USB-C Security Key","b90e7dc1-316e-4fee-a25a-56a666a670fe":"YubiKey 5 Series with Lightning (Enterprise Profile)","6d4aa745-dad5-40c4-b9b4-6a252fcee70f":"GoTrust Cyber Key","175cd298-83d2-4a26-b637-313c07a6434e":"Chunghwa Telecom FIDO2 Smart Card Authenticator","34744913-4f57-4e6e-a527-e9ec3c4b94e6":"YubiKey Bio Series - Multi-protocol Edition","5ea308b2-7ac7-48b9-ac09-7e2da9015f8c":"Veridium Android SDK","3b1adb99-0dfe-46fd-90b8-7f7614a4de2a":"GoTrust Idem Key","46544d5d-8f5d-4db4-89ac-ea8977073fff":"Foongtone FIDO Authenticator","0a357157-9b18-4c8a-920e-d156e972b2f8":"YubiKey 5 Series (Consumer Profile)","998f358b-2dd2-4cbe-a43a-e8107438dfb3":"OnlyKey Secp256R1 FIDO2 CTAP2 Authenticator","30b5035e-d297-4ff2-010b-addc96ba6a98":"OneSpan DIGIPASS FX2-A","817cdab8-0d51-4de1-a821-e25b88519cf3":"Swissbit iShield Key 2 FIPS","61250591-b2bc-4456-b719-0b17be90bb30":"eWBM eFPA FIDO2 Authenticator","8c39ee86-7f9a-4a95-9ba3-f6b097e5c2ee":"YubiKey Bio Series - FIDO Edition (Enterprise Profile)","f8a011f3-8c0a-4d15-8006-17111f9edc7d":"Security Key by Yubico","8976631b-d4a0-427f-5773-0ec71c9e0279":"Solo Tap Secp256R1 FIDO2 CTAP2 Authenticator","7dab85a5-d16d-4eaf-a7ef-4c1385b151c5":"YubiKey 5 Series with NFC (Consumer Profile) KVZR57-2","516d3969-5a57-5651-5958-4e7a49434167":"SmartDisplayer BobeePass FIDO2 Authenticator","8681a073-5f50-4d52-bce4-e21658d207b3":"RSA Authenticator 4 for iOS","30b5035e-d297-4ff7-030b-addc96ba6a98":"OneSpan DIGIPASS FX7-C","e41b42a3-60ac-4afb-8757-a98f2d7f6c9f":"Deepnet SafeKey/Classic (FP)","c89e6a38-6c00-5426-5aa5-c9cbf48f0382":"ACS FIDO Authenticator NFC","eb7ef748-cbe0-4b40-b8f6-07bd2d592d35":"YubiKey 5 CCN Series with NFC (Consumer Profile)","a02167b9-ae71-4ac7-9a07-06432ebb6f1c":"YubiKey 5 Series with Lightning","82b0a720-127a-4788-b56d-d1d4b2d82eac":"ID-One Key","2c0df832-92de-4be1-8412-88a8f074df4a":"Feitian FIDO Smart Card","59f85fe7-faa5-4c92-9f52-697b9d4d5473":"RSA Authenticator 4 for Android","22682ee5-4f0e-4c19-be3e-797d5770ebfe":"Precision InnaIT Key FIDO 2 Level 2 certified","79f3c8ba-9e35-484b-8f47-53a5a0f5c630":"YubiKey 5 FIPS Series with NFC (Enterprise Profile)","6e34341a-88c7-483d-b777-a425c355be93":"WebComm OETHenticator","7a53c643-9dec-4219-b3a4-f9d24aca4e12":"G+D StarKey FIDO2-NFC","def8ab1a-9f91-44f1-a103-088d8dc7d681":"IDEMIA SOLVO Fly 80 R3 FIDO Card e","9955a1cd-564c-d388-ad68-9878a27be9f1":"StarSign Chase FIDO Card","9dd8d593-2213-438a-97f8-d6b813d51c27":"YubiKey Bio Fido Edition (Consumer Profile)","970c8d9c-19d2-46af-aa32-3f448db49e35":"WinMagic FIDO Eazy - TPM","c5703116-972b-4851-a3e7-ae1259843399":"NEOWAVE Badgeo FIDO2","c80dbd9a-533f-4a17-b941-1a2f1c7cedff":"HID Crescendo C3000","0b8b05a4-ebd4-4b0b-8f5f-33d7b6e606ab":"HID Crescendo 4000","5b0e46ba-db02-44ac-b979-ca9b84f5e335":"YubiKey 5 FIPS Series with Lightning Preview","12755c32-8ad1-46eb-881c-e0b38d848b09":"Feitian ePass FIDO Authenticator (CTAP2.1, CTAP2.0, U2F)","2a55aee6-27cb-42c0-bc6e-04efe999e88a":"HID Crescendo 4000","820d89ed-d65a-409e-85cb-f73f0578f82a":"IDmelon Authenticator","019614a3-2703-7e35-a453-285fd06c5d24":"ATLKey Authenticator","3124e301-f14e-4e38-876d-fbeeb090e7bf":"YubiKey 5 Series with Lightning Preview","b6ede29c-3772-412c-8a78-539c1f4c62d2":"Feitian BioPass FIDO2 Plus Authenticator","ed042a3a-4b22-4455-bb69-a267b652ae7e":"Security Key NFC by Yubico - Enterprise Edition","b2c1a50b-dad8-4dc7-ba4d-0ce9597904bc":"YubiKey 5 Series with NFC - Enhanced PIN (Enterprise Profile)","85203421-48f9-4355-9bc8-8a53846e5083":"YubiKey 5 FIPS Series with Lightning","fcc0118f-cd45-435b-8da1-9782b2da0715":"YubiKey 5 FIPS Series with NFC","d821a7d4-e97c-4cb6-bd82-4237731fd4be":"Hyper FIDO Bio Security Key","9876631b-d4a0-427f-5773-0ec71c9e0279":"Somu Secp256R1 FIDO2 CTAP2 Authenticator","522a3f91-5f5d-480d-be37-6cecfad5a27b":"Precision InnaIT Key FIDO 2 Level 2 certified","f56f58b3-d711-4afc-ba7d-6ac05f88cb19":"WinMagic FIDO Eazy - Phone","6ec5cff2-a0f9-4169-945b-f33b563f7b99":"YubiKey Bio Series - Multi-protocol Edition (Enterprise Profile)","882adaf5-3aa9-4708-8e7d-3957103775b4":"T-Shield TrustSec FIDO2 Bio and client PIN version","49a15c1c-3f63-3f51-23a7-b9e00096edd1":"IDEX CTAP2.1 Biometrics","f4c63eff-d26c-4248-801c-3736c7eaa93a":"FIDO KeyPass S3","d384db22-4d50-ebde-2eac-5765cf1e2a44":"Excelsecu eSecu FIDO2 Fingerprint Security Key","0db01cd6-5618-455b-bb46-1ec203d3213e":"GoldKey Security Token","b93fd961-f2e6-462f-b122-82002247de78":"Android Authenticator","aa79f476-ea00-417e-9628-1e8365123922":"HID Crescendo 4000 FIDO","1e906e14-77af-46bc-ae9f-fe6ef18257e4":"VeridiumID Passkey iOS SDK","2fc0579f-8113-47ea-b116-bb5a8db9202a":"YubiKey 5 Series with NFC","31c3f7ff-bf15-4327-83ec-9336abcbcd34":"WinMagic FIDO Eazy - Software","cb4f796c-a20a-af9e-d639-213c1ec247f3":"ACS PocketKey+ Bio","686de81e-fff2-41c1-bb83-2723aaf3e913":"IIST SASE USB KEY 1","9ddd1817-af5a-4672-a2b9-3e3dd95000a9":"Windows Hello","18852056-063b-4042-9814-7d0d383081f9":"SECORA ID Key S USB by Infineon Enterprise Edition","d8522d9f-575b-4866-88a9-ba99fa02f35b":"YubiKey Bio Series - FIDO Edition","050dd0bc-ff20-4265-8d5d-305c4b215192":"eToken Fusion FIPS","50a45b0c-80e7-f944-bf29-f552bfa2e048":"ACS FIDO Authenticator","f7c558a0-f465-11e8-b568-0800200c9a66":"KONAI Secp256R1 FIDO2 Conformance Testing CTAP2 Authenticator","3f59672f-20aa-4afe-b6f4-7e5e916b6d98":"Arculus FIDO 2.1 Key Card [P71]","42b4fb4a-2866-43b2-9bf7-6c6669c2e5d3":"Google Titan Security Key v2","361a3082-0278-4583-a16f-72a527f973e4":"eWBM eFA500 FIDO2 Authenticator","2ffd6452-01da-471f-821b-ea4bf6c8676a":"IDPrime 941 Fido","9806a2c8-c0da-478e-b4ca-620005d34182":"YubiKey Bio Multi-protocol Edition (Consumer Profile) 1VDJSN-2","03012cb7-4fb2-42e7-9e8d-a81f10e2a5e9":"YubiKey 5 Series with Lightning (Consumer Profile)","30b5035e-d297-4ff7-b00b-addc96ba6a98":"OneSpan DIGIPASS FX7","0f083f18-4105-43a8-ad69-24e812e38141":"Security Key Series with NFC (Consumer Profile)","5eaff75a-dd43-451f-af9f-87c9eeae293e":"Swissbit iShield Key 2 FIPS Enterprise","b415094c-49d3-4c8b-b3fe-7d0ad28a6bc4":"ZTPass SmartAuth","692db549-7ae5-44d5-a1e5-dd20a493b723":"HID Crescendo Key","0ebd9f2c-f685-441c-8c3e-a02a234a840a":"YubiKey 5 Series with NFC Enhanced PIN (Consumer Profile)","23315ad0-6aca-4ba1-952e-f044f1e36976":"Clife Key 2 NFC","1d1b4e33-76a1-47fb-97a0-14b10d0933f1":"Cryptnox FIDO2.1","bbf4b6a7-679d-f6fc-c4f2-8ac0ddf9015a":"Excelsecu eSecu FIDO2 PRO Security Key","3e22415d-7fdf-4ea4-8a0c-dd60c4249b9d":"Feitian iePass FIDO Authenticator","ab7d1767-3fa0-4388-b6c4-feef7a844809":"Enterprise Security Key Series with NFC (Enterprise Profile)","f4ce5fc0-57d3-46f5-a736-efb7d5bc63b5":"YubiKey 5 Series with NFC (Consumer Profile)","23786452-f02d-4344-87ed-aaf703726881":"SafeNet eToken Fusion CC","5e264d9d-28ef-4d34-95b4-5941e7a4faa8":"Ideem ZSM FIDO2 Authenticator","d2fbd093-ee62-488d-9dad-1e36389f8826":"YubiKey 5 FIPS Series (RC Preview)","234cd403-35a2-4cc2-8015-77ea280c77f5":"Feitian ePass FIDO2-NFC Series (CTAP2.1, CTAP2.0, U2F)","6999180d-630c-442d-b8f7-424b90a43fae":"Hyper FIDO Pro (CTAP2.1, CTAP2.0, U2F)","662ef48a-95e2-4aaa-a6c1-5b9c40375824":"YubiKey 5 Series with NFC - Enhanced PIN","aeb6569c-f8fb-4950-ac60-24ca2bbe2e52":"HID Crescendo C2300","87dbc5a1-4c94-4dc8-8a47-97d800fd1f3c":"eWBM eFA320 FIDO2 Authenticator","58276709-bb4b-4bb3-baf1-60eea99282a7":"YubiKey Bio Series - Multi-protocol Edition 1VDJSN","7d2afadd-bf6b-44a2-a66b-e831fceb8eff":"Taglio CTAP2.1 EP","30b5035e-d297-4ff1-020b-addc96ba6a98":"OneSpan DIGIPASS FX1-C","04a8fcf2-19c1-457b-911e-69219f17583f":"Thales PAY GFCX13 authenticator","20ac7a17-c814-4833-93fe-539f0d5e3389":"YubiKey 5 Series (Enterprise Profile)","9012593f-43e4-4461-a97a-d92777b55d74":"VinCSS FIDO2 Fingerprint","d7781e5d-e353-46aa-afe2-3ca49f13332a":"YubiKey 5 Series with NFC","9f0d8150-baa5-4c00-9299-ad62c8bb4e87":"GoTrust Idem Card","12ded745-4bed-47d4-abaa-e713f51d6393":"Feitian AllinOne FIDO2 Authenticator","88bbd2f0-342a-42e7-9729-dd158be5407a":"Precision InnaIT Key FIDO 2 Level 2 certified","1d8cac46-47a1-3386-af50-e88ae46fe802":"Ledger Flex FIDO2 Authenticator","dd86a2da-86a0-4cbe-b462-4bd31f57bc6f":"YubiKey Bio Series - FIDO Edition","773c30d9-5919-4e96-a4f5-db65e95cf890":"GSTAG OAK FIDO2 Authenticator","34f5766d-1536-4a24-9033-0e294e510fb0":"YubiKey 5 Series with NFC Preview","83c47309-aabb-4108-8470-8be838b573cb":"YubiKey Bio Series - FIDO Edition (Enterprise Profile)","c1288a5c-d66b-495c-a68f-4e81f9ec5b53":"Deepnet SafeKey/Classic (FP) XF","4e2ddbc2-2687-4709-8551-cb66c9776bfe":"SECORA ID V2 FIDO2.1 L1","be727034-574a-f799-5c76-0929e0430973":"Crayonic KeyVault K1 (USB-NFC-BLE FIDO2 Authenticator)","092277e5-8437-46b5-b911-ea64b294acb7":"Taglio CTAP2.1 CS","ca87cb70-4c1b-4579-a8e8-4efdd7c007e0":"FIDO Alliance TruU Sample FIDO2 Authenticator","23195a52-62d9-40fa-8ee5-23b173f4fb52":"Hyper FIDO Pro NFC","ba0a9266-40d8-4048-9786-d710b5474752":"YubiKey Bio Multi-protocol Edition (Consumer Profile)","3e9db280-256a-4e17-b08e-19d79e9be166":"SECORA ID V2 by Infineon Pay Edition","a7fc3f84-86a3-4da4-a3d7-eb6485a066d8":"NEOWAVE Badgeo FIDO2 (CTAP 2.1)","1f8e43df-71ff-e11d-bea3-c4ee7003b232":"Thetis Pro FIDO2 Key","3ec9c8d3-a5a7-415b-a7b5-f1d606368d3f":"YubiKey 5 CCN Series with NFC (Enterprise Profile)","9e66c661-e428-452a-a8fb-51f7ed088acf":"YubiKey 5 FIPS Series with Lightning (RC Preview)","58b44d0b-0a7c-f33a-fd48-f7153c871352":"Ledger Nano S Plus FIDO2 Authenticator","9a3f2abd-a73d-439c-9ee7-1b53a857eaa7":"YubiKey 5 Series with NFC Enhanced PIN (Enterprise Profile)","454e5346-4944-4ffd-6c93-8e9267193e9b":"Ensurity AUTH BioPro","41e39911-c669-4811-b860-c6ad0b411b96":"YubiKey 5 Series with NFC (Enterprise Profile)","146e77ef-11eb-4423-b847-ce77864e9411":"eToken Fusion NFC PIV","13ac47cf-1d78-4fd5-9060-aedaabacf826":"HID Crescendo Key V3 - Enterprise Edition","e77e3c64-05e3-428b-8824-0cbeb04b829d":"Security Key NFC by Yubico","33d6d7d0-279f-4ef3-96b3-2d3282f4bde6":"Thales eToken Fusion BIO Enterprise","8d4378b0-725d-4432-b3c2-01fcdaf46286":"VeridiumID Passkey Android SDK","7409272d-1ff9-4e10-9fc9-ac0019c124fd":"YubiKey Bio Series - FIDO Edition","bb66c294-de08-47e4-b7aa-d12c2cd3fb20":"Mettlesemi Vishwaas Hawk Authenticator using FIDO2","c4ddaf11-3032-4e77-b3b9-3a340369b9ad":"HID Crescendo Fusion","add92433-0d69-4026-8166-29b25bce64e9":"YubiKey Bio Fido Edition (Enterprise Profile)","7d1351a6-e097-4852-b8bf-c9ac5c9ce4a3":"YubiKey Bio Series - Multi-protocol Edition","07a9f89c-6407-4594-9d56-621d5f1e358b":"NXP Semiconductros FIDO2 Conformance Testing CTAP2 Authenticator","d61d3b87-3e7c-4aea-9c50-441c371903ad":"KeyVault Secp256R1 FIDO2 CTAP2 Authenticator","c62100de-759b-4bf8-b22b-63b3e3a80401":"Token Ring 3 FIDO2 Authenticator","5ca1ab1e-1337-fa57-f1d0-a117e71ca702":"Allthenticator iOS App: roaming BLE FIDO2 Allthenticator for Windows, Mac, Linux, and Allthenticate door readers","b92c3f9a-c014-4056-887f-140a2501163b":"Security Key by Yubico","54d9fee8-e621-4291-8b18-7157b99c5bec":"HID Crescendo Enabled","72a2b5b1-95a5-4df9-a881-4192aff4f72e":"GoTrust Idem Key mini","a25342c0-3cdc-4414-8e46-f4807fca511c":"YubiKey 5 Series with NFC","3a662962-c6d4-4023-bebb-98ae92e78e20":"YubiKey 5 FIPS Series with Lightning (Enterprise Profile)","20f0be98-9af9-986a-4b42-8eca4acb28e4":"Excelsecu eSecu FIDO2 Fingerprint Security Key","ca4cff1b-5a81-4404-8194-59aabcf1660b":"IDPrime 3930 FIDO","ab32f0c6-2239-afbb-c470-d2ef4e254db6":"TEST (DUMMY RECORD)","760eda36-00aa-4d29-855b-4012a182cdeb":"Security Key NFC by Yubico Preview","6028b017-b1d4-4c02-b4b3-afcdafc96bb2":"Windows Hello","b12eac35-586c-4809-a4b1-d81af6c305cf":"Deepnet SafeKey/Classic (NFC)","30b5035e-d297-4fc1-b00b-addc96ba6a97":"OneSpan FIDO Touch","560a780c-b6ae-4f03-b110-082f856425b4":"KQC QuKey Bio FIDO2 Authenticator","1ac71f64-468d-4fe0-bef1-0e5f2f551f18":"YubiKey 5 Series with NFC (Enterprise Profile)","6d44ba9b-f6ec-2e49-b930-0c8fe920cb73":"Security Key by Yubico with NFC","6832d205-75f2-44c7-a864-e868c796d06e":"Precision InnaIT Key FIDO 2 Level 2 certified","9eb85bb6-9625-4a72-815d-0487830ccab2":"Ensurity AUTH BioPro Desktop","30b5035e-d297-4ff7-010b-addc96ba6a98":"OneSpan DIGIPASS FX7-B","5ca1ab1e-fa57-1337-f1d0-a117371ca702":"Allthenticator Android App: roaming BLE FIDO2 Allthenticator for Windows, Mac, Linux, and Allthenticate door readers","eabb46cc-e241-80bf-ae9e-96fa6d2975cf":"TOKEN2 PIN Plus Security Key Series ","53414d53-554e-4700-0000-000000000000":"Samsung Pass","e416201b-afeb-41ca-a03d-2281c28322aa":"ATKey.Pro CTAP2.1","905b4cb4-ed6f-4da9-92fc-45e0d4e9b5c7":"YubiKey 5 FIPS Series (Enterprise Profile)","dee49ee1-11cb-47b6-bed0-8e995e67a0fb":"SECORA Connect SLS21 D1 FIDO 2.1 v1.0 by Infineon - Consumer Edition","cfcb13a2-244f-4b36-9077-82b79d6a7de7":"USB/NFC Passcode Authenticator","76692dc1-c56a-48d9-8e7d-31b5ced430ac":"VeriMark NFC+ USB-A Security Key","91ad6b93-264b-4987-8737-3a690cad6917":"Token Ring FIDO2 Authenticator","a02140b7-0cbd-42e1-a9b5-a39da2545114":"Feitian BioPass FIDO2 Plus (Enterprise Profile)","5753362b-4e6b-6345-7b2f-255438404c75":"WiSECURE Blentity FIDO2 Authenticator","9f77e279-a6e2-4d58-b700-31e5943c6a98":"Hyper FIDO Pro","b9f6b7b6-f929-4189-bca9-dd951240c132":"Deepnet SafeKey/Classic (USB)","cc45f64e-52a2-451b-831a-4edd8022a202":"ToothPic Passkey Provider","0bb43545-fd2c-4185-87dd-feb0b2916ace":"Security Key NFC by Yubico - Enterprise Edition","73402251-f2a8-4f03-873e-3cb6db604b03":"uTrust FIDO2 Security Key","c1f9a0bc-1dd2-404a-b27f-8e29047a43fd":"YubiKey 5 FIPS Series with NFC","70e7c36f-f2f6-9e0d-07a6-bcc243262e6b":"OneKey FIDO2 Bluetooth Authenticator","4fc84f16-2545-4e53-b8fc-7bf4d7282a10":"YubiKey 5 CCN Series with NFC (Enterprise Profile)","6ab56fad-881f-4a43-acb2-0be065924522":"YubiKey 5 Series with NFC (Enterprise Profile)","504d7149-4e4c-3841-4555-55445a677357":"WiSECURE AuthTron USB FIDO2 Authenticator","2c2aeed8-8174-4159-814b-486e92a261d0":"NEOWAVE WINKEO V2.0","f2145e86-211e-4931-b874-e22bba7d01cc":"ID-One Key","53334693-4b3f-4198-8857-53772de2ab65":"Precision InnaIT Key FIDO 2 Level 2 certified","a3975549-b191-fd67-b8fb-017e2917fdb3":"Excelsecu eSecu FIDO2 NFC Security Key","19083c3d-8383-4b18-bc03-8f1c9ab2fd1b":"YubiKey 5 Series","da1fa263-8b25-42b6-a820-c0036f21ba7f":"ATKey.Card NFC","6002f033-3c07-ce3e-d0f7-0ffe5ed42543":"Excelsecu eSecu FIDO2 Fingerprint Key","5fdb81b8-53f0-4967-a881-f5ec26fe4d18":"VinCSS FIDO2 Authenticator","78ba3993-d784-4f44-8d6e-cc0a8ad5230e":"Feitian ePass FIDO-NFC(CTAP2.1, CTAP2.0, U2F)","57f7de54-c807-4eab-b1c6-1c9be7984e92":"YubiKey 5 FIPS Series","bb405265-40cf-4115-93e5-a332c1968d8c":"ID-One Card","2d3bec26-15ee-4f5d-88b2-53622490270b":"HID Crescendo Key V2","489ff376-b48d-6640-bb69-782a860ca795":"Mettlesemi Vishwaas Eagle Authenticator using FIDO2","3b24bf49-1d45-4484-a917-13175df0867b":"YubiKey 5 Series with Lightning (Enterprise Profile)","30b5035e-d297-4ff1-010b-addc96ba6a98":"OneSpan DIGIPASS FX1a","cb69481e-8ff7-4039-93ec-0a2729a154a8":"YubiKey 5 Series","0076631b-d4a0-427f-5773-0ec71c9e0279":"HYPR FIDO2 Authenticator","d716019a-9f4e-4041-9750-17c78f8ae81a":"eToken Fusion BIO","57235694-51a5-4a4d-a81a-f42185df6502":"SHALO AUTH","24673149-6c86-42e7-98d9-433fb5b73296":"YubiKey 5 Series with Lightning","357f2718-434f-4124-8a58-7e28c5e4a2fc":"Deepnet SafeKey/Classic (NFC)","42df17de-06ba-4177-a2bb-6701be1380d6":"Feitian BioPass FIDO2 Plus Authenticator","d7a423ad-3e19-4492-9200-78137dccc136":"VivoKey Apex FIDO2","b3315166-f36c-b05f-fea8-66a3dfdad171":"Ledger Nano Gen5 FIDO2 Authenticator","ba76a271-6eb6-4171-874d-b6428dbe3437":"ATKey.ProS","97e6a830-c952-4740-95fc-7c78dc97ce47":"YubiKey Bio Series - Multi-protocol Edition (Enterprise Profile)","f573f209-b7fb-b261-671a-d7cf624cc812":"Excelsecu eSecu FIDO2 PRO+ Security Key","005b20e1-f146-4b87-8f3a-36848ff60ea6":"SECORA ID V2 by Infineon Pay Edition M","6e24d385-004a-16a0-7bfe-efd963845b34":"Ledger Stax FIDO2 Authenticator","ee882879-721c-4913-9775-3dfcce97072a":"YubiKey 5 Series","8876631b-d4a0-427f-5773-0ec71c9e0279":"Solo Secp256R1 FIDO2 CTAP2 Authenticator","fec067a1-f1d0-4c5e-b4c0-cc3237475461":"KX701 SmartToken FIDO","30b5035e-d297-4ff1-b00b-addc96ba6a98":"OneSpan DIGIPASS FX1 BIO","b267239b-954f-4041-a01b-ee4f33c145b6":"authenton1 - CTAP2.1","b50d5e0a-7f81-4959-9b12-f45407407503":"IDPrime 3940 FIDO","8c97a730-3f7b-41a6-87d6-1e9b62bda6f0":"FT-JCOS FIDO Fingerprint Card","99bf4610-ec26-4252-b31f-7380ccd59db5":"ZTPass SmartAuth","a1f52be5-dfab-4364-b51c-2bd496b14a56":"OCTATCO EzFinger2 FIDO2 AUTHENTICATOR","c3479970-e58a-4f70-836f-853bf42fb063":"YubiKey 5 Series with Lightning (Enterprise Profile)","0f00cc22-4640-41e7-9585-384ec73ffe9b":"Taglio CTAP2.1 BIO","ff4dac45-ede8-4ec2-aced-cf66103f4335":"YubiKey 5 Series","ba86dc56-635f-4141-aef6-00227b1b9af6":"TruU Windows Authenticator","3e078ffd-4c54-4586-8baa-a77da113aec5":"Hideez Key 3 FIDO2","fc5ca237-69a0-4f3c-afe4-1ebc66def6df":"Clife Key 2","ec31b4cc-2acc-4b8e-9c01-bade00ccbe26":"KeyXentic FIDO2 Secp256R1 FIDO2 CTAP2 Authenticator","5d629218-d3a5-11ed-afa1-0242ac120002":"Swissbit iShield Key Pro","bb878d7b-cf54-4784-b390-357030497043":"TruU FIDO2 Authenticator","d41f5a69-b817-4144-a13c-9ebd6d9254d6":"ATKey.Card CTAP2.0","e86addcd-7711-47e5-b42a-c18257b0bf61":"IDCore 3121 Fido","8108bdcd-8483-46b3-b3ce-359f8190325e":"SECORA ID V2 by Infineon Enterprise Edition","b113a455-cfb6-4c17-8cba-cd952feb7d48":"eToken FIDO NFC","95442b2e-f15e-4def-b270-efb106facb4e":"eWBM eFA310 FIDO2 Authenticator","dda9aa35-aaf1-4d3c-b6db-7902fd7dbbbf":"IDEMIA SOLVO Fly 80 R3 FIDO Card c","cdbdaea2-c415-5073-50f7-c04e968640b6":"Excelsecu eSecu FIDO2 Security Key","3aa78eb1-ddd8-46a8-a821-8f8ec57a7bd5":"YubiKey 5 CCN Series with NFC","bc2fe499-0d8e-4ffe-96f3-94a82840cf8c":"OCTATCO EzQuant FIDO2 AUTHENTICATOR","eb3b131e-59dc-536a-d176-cb7306da10f5":"ellipticSecure MIRkey USB Authenticator","3fd410dc-8ab7-4b86-a1cb-c7174620b2dc":"IDEMIA SOLVO Fly 80 R1 FIDO Card Draft","a6c5f5d8-2ad0-48b6-8257-e502c8970931":"eToken FIDO NFC Enterprise","e400ef8c-711d-4692-af46-7f2cf7da23ad":"Swissbit iShield Key 2 Enterprise","87c13177-85d6-40ac-8c61-fe7ab3de9dfb":"HID Crescendo Key V3","1c086528-58d5-f211-823c-356786e36140":"Atos CardOS FIDO2","77010bd7-212a-4fc9-b236-d2ca5e9d4084":"Feitian BioPass FIDO2 Authenticator","3b3faa7b-2c56-4489-bcd3-53b83ee75768":"SECORA Connect SLS21 D1 FIDO 2.1 v1.0 by Infineon - Enterprise Edition","d94a29d9-52dd-4247-9c2d-8b818b610389":"VeriMark Guard Fingerprint Key","7b96457d-e3cd-432b-9ceb-c9fdd7ef7432":"YubiKey 5 FIPS Series with Lightning","7991798a-a7f3-487f-98c0-3faf7a458a04":"HID Crescendo Key V3","833b721a-ff5f-4d00-bb2e-bdda3ec01e29":"Feitian ePass FIDO2 Authenticator","c89674e3-a765-4b07-888a-7c086fbdf04b":"StarSign FIDO Card","a11a5faa-9f32-4b8c-8c5d-2f7d13e8c942":"AliasVault","ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4":"Google Password Manager","adce0002-35bc-c60a-648b-0b25f1f05503":"Chrome on Mac","dd4ec289-e01d-41c9-bb89-70fa845d4bf2":"iCloud Keychain (Managed)","531126d6-e717-415c-9320-3d9aa6981239":"Dashlane","bada5566-a7aa-401f-bd96-45619a55120d":"1Password","b84e4048-15dc-4dd0-8640-f4f60813c8af":"NordPass","0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6":"Keeper","891494da-2c90-4d31-a9cd-4eab0aed1309":"Sésame","f3809540-7f14-49c1-a8b3-8f813b225541":"Enpass","b5397666-4885-aa6b-cebf-e52262a439a2":"Chromium Browser","771b48fd-d3d4-4f74-9232-fc157ab0507a":"Edge on Mac","d548826e-79b4-db40-a3d8-11116f7e8349":"Bitwarden","fbfc3007-154e-4ecc-8c0b-6e020557d7bd":"Apple Passwords","66a0ccb3-bd6a-191f-ee06-e375c50b9846":"Thales Bio iOS SDK","8836336a-f590-0921-301d-46427531eee6":"Thales Bio Android SDK","cd69adb5-3c7a-deb9-3177-6800ea6cb72a":"Thales PIN Android SDK","17290f1e-c212-34d0-1423-365d729f09d9":"Thales PIN iOS SDK","50726f74-6f6e-5061-7373-50726f746f6e":"Proton Pass","fdb141b2-5d84-443e-8a35-4698c205a502":"KeePassXC","eaecdef2-1c31-5634-8639-f1cbd9c00a08":"KeePassDX","9addb28c-b46f-4402-808f-019651441ff3":"KeePassPasskey","bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0":"iPasswords","b35a26b2-8f6e-4697-ab1d-d44db4da28c6":"Zoho Vault","b78a0a55-6ef8-d246-a042-ba0f6d55050c":"LastPass","de503f9c-21a4-4f76-b4b7-558eb55c6f89":"Devolutions","22248c4c-7a12-46e2-9a41-44291b373a4d":"LogMeOnce","a10c6dd9-465e-4226-8198-c7c44b91c555":"Kaspersky Password Manager","d350af52-0351-4ba2-acd3-dfeeadc3f764":"pwSafe","d3452668-01fd-4c12-926c-83a4204853aa":"Microsoft Password Manager","6d212b28-a2c1-4638-b375-5932070f62e9":"initial","d49b2120-b865-4191-8cea-be84a52b0485":"Heimlane Vault","d9be9d39-e6a6-4c28-a581-32b044d986e4":"Sticky Password Manager","70617373-7761-6c6c-6669-646f32303236":"Passwall","c9cadfc9-89a9-489e-a25a-c7e86a4d5f15":"Burp Suite Navigation Recorder","fa37f553-f9b6-4adb-ac53-8bbb57ebdf0d":"Norton Password Manager","a4a2d88e-9796-4356-9164-e2a5a8bd019c":"Avast Password Manager","e7db2bd3-f2fe-4d71-ad78-7e7aa166cfd1":"Avira Password Manager","6bb49926-160a-4306-a100-4eb39ba6ac45":"AVG Password Manager"} +{"fcb1bcb4-f370-078c-6993-bc24d0ae3fbe":"Ledger Nano X FIDO2 Authenticator","6e8d1eae-8d40-4c25-bcf8-4633959afc71":"Veridium iOS SDK","e8b7f4a2-c3d5-e6f7-890a-b1c2d3e4f567":"Sherlocked","9eb7eabc-9db5-49a1-b6c3-555a802093f4":"YubiKey 5 Series with NFC KVZR57","4d41190c-7beb-4a84-8018-adf265a6352d":"Thales IDPrime FIDO Bio","2772ce93-eb4b-4090-8b73-330f48477d73":"Security Key NFC by Yubico - Enterprise Edition Preview","6dae43be-af9c-417b-8b9f-1b611168ec60":"Dapple Authenticator from Dapple Security Inc.","5626bed4-e756-430b-a7ff-ca78c8b12738":"VALMIDO PRO FIDO","260e3021-482d-442d-838c-7edfbe153b7e":"Feitian ePass FIDO2-NFC Plus Authenticator","95e4d58c-056e-4a65-866d-f5a69659e880":"TruU Windows Authenticator","90636e1f-ef82-43bf-bdcf-5255f139d12f":"YubiKey Bio Series - Multi-protocol Edition","9c835346-796b-4c27-8898-d6032f515cc5":"Cryptnox FIDO2","c3f47802-de73-4dfc-ba22-671fe3304f90":"eToken Fusion NFC PIV Enterprise","0d9b2e56-566b-c393-2940-f821b7f15d6d":"Excelsecu eSecu FIDO2 Pro Security Key","2bff89f2-323a-48fc-b7c8-9ff7fe87c07e":"Feitian BioPass FIDO2 Pro (Enterprise Profile)","c5ef55ff-ad9a-4b9f-b580-adebafe026d0":"YubiKey 5 Series with Lightning","2194b428-9397-4046-8f39-007a1605a482":"IDPrime 931 Fido","39a5647e-1853-446c-a1f6-a79bae9f5bc7":"IDmelon","664d9f67-84a2-412a-9ff7-b4f7d8ee6d05":"OpenSK authenticator","3789da91-f943-46bc-95c3-50ea2012f03a":"NEOWAVE Winkeo FIDO2","fa2b99dc-9e39-4257-8f92-4a30d23c4118":"YubiKey 5 Series with NFC","341e4da9-3c2e-8103-5a9f-aad887135200":"Ledger Nano S FIDO2 Authenticator","69700f79-d1fb-472e-bd9b-a3a3b9a9eda0":"Pone Biometrics OFFPAD Authenticator","8da0e4dc-164b-454e-972e-88f362b23d59":"CardOS FIDO2 Token","89b19028-256b-4025-8872-255358d950e4":"Sentry Enterprises CTAP2 Authenticator","4e768f2c-5fab-48b3-b300-220eb487752b":"Hideez Key 4 FIDO2 SDK","47ab2fb4-66ac-4184-9ae1-86be814012d5":"Security Key NFC by Yubico - Enterprise Edition","931327dd-c89b-406c-a81e-ed7058ef36c6":"Swissbit iShield Key FIDO2","f8d5c4e9-e539-4c06-8662-ec2a4155a555":"StarSign Key Fob","b7d3f68e-88a6-471e-9ecf-2df26d041ede":"Security Key NFC by Yubico","8d1b1fcb-3c76-49a9-9129-5515b346aa02":"IDEMIA ID-ONE Card","30b5035e-d297-4ff7-020b-addc96ba6a98":"OneSpan DIGIPASS FX7","454e5346-4944-4ffd-6c93-8e9267193e9a":"Ensurity ThinC","e1a96183-5016-4f24-b55b-e3ae23614cc6":"ATKey.Pro CTAP2.0","9ff4cc65-6154-4fff-ba09-9e2af7882ad2":"Security Key NFC by Yubico - Enterprise Edition (Enterprise Profile)","4599062e-6926-4fe7-9566-9e8fb1aedaa0":"YubiKey 5 Series (Enterprise Profile)","9d3df6ba-282f-11ed-a261-0242ac120002":"Arculus FIDO2/U2F Key Card","fbefdf68-fe86-0106-213e-4d5fa24cbe2e":"Excelsecu eSecu FIDO2 NFC Security Key","62e54e98-c209-4df3-b692-de71bb6a8528":"YubiKey 5 FIPS Series with NFC Preview","ab32f0c6-2239-afbb-c470-d2ef4e254db7":"TOKEN2 FIDO2 Security Key","ce6bf97f-9f69-4ba7-9032-97adc6ca5cf1":"YubiKey 5 FIPS Series with NFC (RC Preview)","ad08c78a-4e41-49b9-86a2-ac15b06899e2":"YubiKey Bio Series - FIDO Edition (Enterprise Profile)","930b0c03-ef46-4ac4-935c-538dccd1fcdb":"Chipwon Clife Key","7787a482-13e8-4784-8a06-c7ed49a7aaf4":"Swissbit iShield Key 2","72c6b72d-8512-4c66-8359-9d3d10d9222f":"Security Key NFC by Yubico - Enterprise Edition (Enterprise Profile)","99ed6c29-4573-4847-816d-78ad8f1c75ef":"VeroCard FIDO2 Authenticator","973446ca-e21c-9a9b-99f5-9b985a67af0f":"ACS FIDO Authenticator Card","238ab2f5-b57f-4917-b3c6-3d3c6c0c350f":"FEITIAN FT-JCOS BioCard","74820b05-a6c9-40f9-8fb0-9f86aca93998":"SafeNet eToken Fusion","1105e4ed-af1d-02ff-ffff-ffffffffffff":"Egomet FIDO2 Authenticator for Android","08987058-cadc-4b81-b6e1-30de50dcbe96":"Windows Hello","a4e9fc6d-4cbe-4758-b8ba-37598bb5bbaa":"Security Key NFC by Yubico","0acf3011-bc60-f375-fb53-6f05f43154e0":"Nymi FIDO2 Authenticator","d91c5288-0ef0-49b7-b8ae-21ca0aa6b3f3":"KEY-ID FIDO2 Authenticator","8eec9bf9-486c-46da-9a67-1fbb4f66b9ed":"HID Crescendo 4000 FIPS","4c50ff10-1057-4fc6-b8ed-43a529530c3c":"ImproveID Authenticator","c611b55c-77b2-4527-8082-590e931b2f08":"GoTrust Idem Key ","ee041bce-25e5-4cdb-8f86-897fd6418464":"Feitian ePass FIDO2-NFC Authenticator","2588ae83-5a3b-4536-b8de-5e540200d191":"Dapple Authenticator from Dapple Security Inc.","4b89f401-464e-4745-a520-486ddfc5d80e":"IIST FIDO2 Authenticator","2cd2f727-f6ca-44da-8f48-5c2e5da000a2":"Nitrokey 3 AM","10c70715-2a9a-4de1-b0aa-3cff6d496d39":"eToken Fusion NFC FIPS","efb96b10-a9ee-4b6c-a4a9-d32125ccd4a4":"Safenet eToken FIDO","24083bcb-3034-4867-99de-a3b52e1d426a":"Enterprise Security Key Series with NFC (Consumer Profile)","4b3f8944-d4f2-4d21-bb19-764a986ec160":"KeyXentic FIDO2 Secp256R1 FIDO2 CTAP2 Authenticator","4c0cf95d-2f40-43b5-ba42-4c83a11c04ba":"Feitian BioPass FIDO2 Pro Authenticator","5343502d-5343-5343-6172-644649444f32":"ESS Smart Card Inc. Authenticator","69e7c36f-f2f6-9e0d-07a6-bcc243262e6b":"OneKey FIDO2 Authenticator","524de2de-982f-49b4-a769-2b5e3b73ad79":"YubiKey 5 Series (Enterprise Profile)","09591fc6-9811-48f7-8f57-b9f23df6413f":"Pone Biometrics OFFPAD Authenticator","912435d9-4a88-42f3-972d-1244b0d51420":"SI0X FIDO CL WRIST v1.0","7e3f3d30-3557-4442-bdae-139312178b39":"RSA DS100","73bb0cd4-e502-49b8-9c6f-b59445bf720b":"YubiKey 5 FIPS Series","39589099-9a75-49fc-afaa-801ca211c62a":"Feitian ePass FIDO-NFC (Enterprise Profile) (CTAP2.1, CTAP2.0, U2F)","dc5e949d-f939-43b3-9877-a85c7186b753":"YubiKey Bio Multi-protocol Edition (Enterprise Profile)","149a2021-8ef6-4133-96b8-81f8d5b7f1f5":"Security Key by Yubico with NFC","5df66f62-5b47-43d3-aa1d-a6e31c8dbeb5":"Securitag Assembly Group FIDO Authenticator NFC","9a272558-5cfa-4424-be37-65509677b77d":"SECORA ID Key S USB by Infineon Consumer Edition","09619fbf-d75e-4a62-be1d-fe4d240864ae":"VeriMark(TM) Guard 2.1 Fingerprint Security Key","55fd881f-40ba-4eaf-8435-42c5fed08b76":"SECORA ID V2 by Infineon Consumer Edition","50cbf15a-238c-4457-8f16-812c43bf3c49":"Ensurity AUTH TouchPro","ee7fa1e0-9539-432f-bd43-9c2fc6d4f311":"VeriMark NFC+ USB-C Security Key","b90e7dc1-316e-4fee-a25a-56a666a670fe":"YubiKey 5 Series with Lightning (Enterprise Profile)","6d4aa745-dad5-40c4-b9b4-6a252fcee70f":"GoTrust Cyber Key","175cd298-83d2-4a26-b637-313c07a6434e":"Chunghwa Telecom FIDO2 Smart Card Authenticator","34744913-4f57-4e6e-a527-e9ec3c4b94e6":"YubiKey Bio Series - Multi-protocol Edition","5ea308b2-7ac7-48b9-ac09-7e2da9015f8c":"Veridium Android SDK","3b1adb99-0dfe-46fd-90b8-7f7614a4de2a":"GoTrust Idem Key","46544d5d-8f5d-4db4-89ac-ea8977073fff":"Foongtone FIDO Authenticator","0a357157-9b18-4c8a-920e-d156e972b2f8":"YubiKey 5 Series (Consumer Profile)","998f358b-2dd2-4cbe-a43a-e8107438dfb3":"OnlyKey Secp256R1 FIDO2 CTAP2 Authenticator","30b5035e-d297-4ff2-010b-addc96ba6a98":"OneSpan DIGIPASS FX2-A","817cdab8-0d51-4de1-a821-e25b88519cf3":"Swissbit iShield Key 2 FIPS","61250591-b2bc-4456-b719-0b17be90bb30":"eWBM eFPA FIDO2 Authenticator","8c39ee86-7f9a-4a95-9ba3-f6b097e5c2ee":"YubiKey Bio Series - FIDO Edition (Enterprise Profile)","f8a011f3-8c0a-4d15-8006-17111f9edc7d":"Security Key by Yubico","8976631b-d4a0-427f-5773-0ec71c9e0279":"Solo Tap Secp256R1 FIDO2 CTAP2 Authenticator","7dab85a5-d16d-4eaf-a7ef-4c1385b151c5":"YubiKey 5 Series with NFC (Consumer Profile) KVZR57-2","516d3969-5a57-5651-5958-4e7a49434167":"SmartDisplayer BobeePass FIDO2 Authenticator","8681a073-5f50-4d52-bce4-e21658d207b3":"RSA Authenticator 4 for iOS","30b5035e-d297-4ff7-030b-addc96ba6a98":"OneSpan DIGIPASS FX7-C","e41b42a3-60ac-4afb-8757-a98f2d7f6c9f":"Deepnet SafeKey/Classic (FP)","c89e6a38-6c00-5426-5aa5-c9cbf48f0382":"ACS FIDO Authenticator NFC","eb7ef748-cbe0-4b40-b8f6-07bd2d592d35":"YubiKey 5 CCN Series with NFC (Consumer Profile)","a02167b9-ae71-4ac7-9a07-06432ebb6f1c":"YubiKey 5 Series with Lightning","82b0a720-127a-4788-b56d-d1d4b2d82eac":"ID-One Key","2c0df832-92de-4be1-8412-88a8f074df4a":"Feitian FIDO Smart Card","59f85fe7-faa5-4c92-9f52-697b9d4d5473":"RSA Authenticator 4 for Android","22682ee5-4f0e-4c19-be3e-797d5770ebfe":"Precision InnaIT Key FIDO 2 Level 2 certified","79f3c8ba-9e35-484b-8f47-53a5a0f5c630":"YubiKey 5 FIPS Series with NFC (Enterprise Profile)","6e34341a-88c7-483d-b777-a425c355be93":"WebComm OETHenticator","7a53c643-9dec-4219-b3a4-f9d24aca4e12":"G+D StarKey FIDO2-NFC","def8ab1a-9f91-44f1-a103-088d8dc7d681":"IDEMIA SOLVO Fly 80 R3 FIDO Card e","9955a1cd-564c-d388-ad68-9878a27be9f1":"StarSign Chase FIDO Card","9dd8d593-2213-438a-97f8-d6b813d51c27":"YubiKey Bio Fido Edition (Consumer Profile)","970c8d9c-19d2-46af-aa32-3f448db49e35":"WinMagic FIDO Eazy - TPM","c5703116-972b-4851-a3e7-ae1259843399":"NEOWAVE Badgeo FIDO2","c80dbd9a-533f-4a17-b941-1a2f1c7cedff":"HID Crescendo C3000","0b8b05a4-ebd4-4b0b-8f5f-33d7b6e606ab":"HID Crescendo 4000","5b0e46ba-db02-44ac-b979-ca9b84f5e335":"YubiKey 5 FIPS Series with Lightning Preview","12755c32-8ad1-46eb-881c-e0b38d848b09":"Feitian ePass FIDO Authenticator (CTAP2.1, CTAP2.0, U2F)","2a55aee6-27cb-42c0-bc6e-04efe999e88a":"HID Crescendo 4000","820d89ed-d65a-409e-85cb-f73f0578f82a":"IDmelon Authenticator","019614a3-2703-7e35-a453-285fd06c5d24":"ATLKey Authenticator","3124e301-f14e-4e38-876d-fbeeb090e7bf":"YubiKey 5 Series with Lightning Preview","b6ede29c-3772-412c-8a78-539c1f4c62d2":"Feitian BioPass FIDO2 Plus Authenticator","ed042a3a-4b22-4455-bb69-a267b652ae7e":"Security Key NFC by Yubico - Enterprise Edition","b2c1a50b-dad8-4dc7-ba4d-0ce9597904bc":"YubiKey 5 Series with NFC - Enhanced PIN (Enterprise Profile)","85203421-48f9-4355-9bc8-8a53846e5083":"YubiKey 5 FIPS Series with Lightning","fcc0118f-cd45-435b-8da1-9782b2da0715":"YubiKey 5 FIPS Series with NFC","d821a7d4-e97c-4cb6-bd82-4237731fd4be":"Hyper FIDO Bio Security Key","9876631b-d4a0-427f-5773-0ec71c9e0279":"Somu Secp256R1 FIDO2 CTAP2 Authenticator","522a3f91-5f5d-480d-be37-6cecfad5a27b":"Precision InnaIT Key FIDO 2 Level 2 certified","f56f58b3-d711-4afc-ba7d-6ac05f88cb19":"WinMagic FIDO Eazy - Phone","6ec5cff2-a0f9-4169-945b-f33b563f7b99":"YubiKey Bio Series - Multi-protocol Edition (Enterprise Profile)","882adaf5-3aa9-4708-8e7d-3957103775b4":"T-Shield TrustSec FIDO2 Bio and client PIN version","49a15c1c-3f63-3f51-23a7-b9e00096edd1":"IDEX CTAP2.1 Biometrics","f4c63eff-d26c-4248-801c-3736c7eaa93a":"FIDO KeyPass S3","d384db22-4d50-ebde-2eac-5765cf1e2a44":"Excelsecu eSecu FIDO2 Fingerprint Security Key","0db01cd6-5618-455b-bb46-1ec203d3213e":"GoldKey Security Token","b93fd961-f2e6-462f-b122-82002247de78":"Android Authenticator","aa79f476-ea00-417e-9628-1e8365123922":"HID Crescendo 4000 FIDO","1e906e14-77af-46bc-ae9f-fe6ef18257e4":"VeridiumID Passkey iOS SDK","2fc0579f-8113-47ea-b116-bb5a8db9202a":"YubiKey 5 Series with NFC","31c3f7ff-bf15-4327-83ec-9336abcbcd34":"WinMagic FIDO Eazy - Software","cb4f796c-a20a-af9e-d639-213c1ec247f3":"ACS PocketKey+ Bio","686de81e-fff2-41c1-bb83-2723aaf3e913":"IIST SASE USB KEY 1","9ddd1817-af5a-4672-a2b9-3e3dd95000a9":"Windows Hello","18852056-063b-4042-9814-7d0d383081f9":"SECORA ID Key S USB by Infineon Enterprise Edition","d8522d9f-575b-4866-88a9-ba99fa02f35b":"YubiKey Bio Series - FIDO Edition","050dd0bc-ff20-4265-8d5d-305c4b215192":"eToken Fusion FIPS","50a45b0c-80e7-f944-bf29-f552bfa2e048":"ACS FIDO Authenticator","f7c558a0-f465-11e8-b568-0800200c9a66":"KONAI Secp256R1 FIDO2 Conformance Testing CTAP2 Authenticator","3f59672f-20aa-4afe-b6f4-7e5e916b6d98":"Arculus FIDO 2.1 Key Card [P71]","42b4fb4a-2866-43b2-9bf7-6c6669c2e5d3":"Google Titan Security Key v2","361a3082-0278-4583-a16f-72a527f973e4":"eWBM eFA500 FIDO2 Authenticator","2ffd6452-01da-471f-821b-ea4bf6c8676a":"IDPrime 941 Fido","9806a2c8-c0da-478e-b4ca-620005d34182":"YubiKey Bio Multi-protocol Edition (Consumer Profile) 1VDJSN-2","03012cb7-4fb2-42e7-9e8d-a81f10e2a5e9":"YubiKey 5 Series with Lightning (Consumer Profile)","30b5035e-d297-4ff7-b00b-addc96ba6a98":"OneSpan DIGIPASS FX7","0f083f18-4105-43a8-ad69-24e812e38141":"Security Key Series with NFC (Consumer Profile)","5eaff75a-dd43-451f-af9f-87c9eeae293e":"Swissbit iShield Key 2 FIPS Enterprise","b415094c-49d3-4c8b-b3fe-7d0ad28a6bc4":"ZTPass SmartAuth","692db549-7ae5-44d5-a1e5-dd20a493b723":"HID Crescendo Key","0ebd9f2c-f685-441c-8c3e-a02a234a840a":"YubiKey 5 Series with NFC Enhanced PIN (Consumer Profile)","23315ad0-6aca-4ba1-952e-f044f1e36976":"Clife Key 2 NFC","1d1b4e33-76a1-47fb-97a0-14b10d0933f1":"Cryptnox FIDO2.1","bbf4b6a7-679d-f6fc-c4f2-8ac0ddf9015a":"Excelsecu eSecu FIDO2 PRO Security Key","3e22415d-7fdf-4ea4-8a0c-dd60c4249b9d":"Feitian iePass FIDO Authenticator","ab7d1767-3fa0-4388-b6c4-feef7a844809":"Enterprise Security Key Series with NFC (Enterprise Profile)","f4ce5fc0-57d3-46f5-a736-efb7d5bc63b5":"YubiKey 5 Series with NFC (Consumer Profile)","23786452-f02d-4344-87ed-aaf703726881":"SafeNet eToken Fusion CC","5e264d9d-28ef-4d34-95b4-5941e7a4faa8":"Ideem ZSM FIDO2 Authenticator","d2fbd093-ee62-488d-9dad-1e36389f8826":"YubiKey 5 FIPS Series (RC Preview)","234cd403-35a2-4cc2-8015-77ea280c77f5":"Feitian ePass FIDO2-NFC Series (CTAP2.1, CTAP2.0, U2F)","6999180d-630c-442d-b8f7-424b90a43fae":"Hyper FIDO Pro (CTAP2.1, CTAP2.0, U2F)","662ef48a-95e2-4aaa-a6c1-5b9c40375824":"YubiKey 5 Series with NFC - Enhanced PIN","aeb6569c-f8fb-4950-ac60-24ca2bbe2e52":"HID Crescendo C2300","87dbc5a1-4c94-4dc8-8a47-97d800fd1f3c":"eWBM eFA320 FIDO2 Authenticator","58276709-bb4b-4bb3-baf1-60eea99282a7":"YubiKey Bio Series - Multi-protocol Edition 1VDJSN","7d2afadd-bf6b-44a2-a66b-e831fceb8eff":"Taglio CTAP2.1 EP","30b5035e-d297-4ff1-020b-addc96ba6a98":"OneSpan DIGIPASS FX1-C","04a8fcf2-19c1-457b-911e-69219f17583f":"Thales PAY GFCX13 authenticator","20ac7a17-c814-4833-93fe-539f0d5e3389":"YubiKey 5 Series (Enterprise Profile)","9012593f-43e4-4461-a97a-d92777b55d74":"VinCSS FIDO2 Fingerprint","d7781e5d-e353-46aa-afe2-3ca49f13332a":"YubiKey 5 Series with NFC","9f0d8150-baa5-4c00-9299-ad62c8bb4e87":"GoTrust Idem Card","12ded745-4bed-47d4-abaa-e713f51d6393":"Feitian AllinOne FIDO2 Authenticator","88bbd2f0-342a-42e7-9729-dd158be5407a":"Precision InnaIT Key FIDO 2 Level 2 certified","1d8cac46-47a1-3386-af50-e88ae46fe802":"Ledger Flex FIDO2 Authenticator","dd86a2da-86a0-4cbe-b462-4bd31f57bc6f":"YubiKey Bio Series - FIDO Edition","773c30d9-5919-4e96-a4f5-db65e95cf890":"GSTAG OAK FIDO2 Authenticator","34f5766d-1536-4a24-9033-0e294e510fb0":"YubiKey 5 Series with NFC Preview","83c47309-aabb-4108-8470-8be838b573cb":"YubiKey Bio Series - FIDO Edition (Enterprise Profile)","c1288a5c-d66b-495c-a68f-4e81f9ec5b53":"Deepnet SafeKey/Classic (FP) XF","4e2ddbc2-2687-4709-8551-cb66c9776bfe":"SECORA ID V2 FIDO2.1 L1","be727034-574a-f799-5c76-0929e0430973":"Crayonic KeyVault K1 (USB-NFC-BLE FIDO2 Authenticator)","092277e5-8437-46b5-b911-ea64b294acb7":"Taglio CTAP2.1 CS","ca87cb70-4c1b-4579-a8e8-4efdd7c007e0":"FIDO Alliance TruU Sample FIDO2 Authenticator","23195a52-62d9-40fa-8ee5-23b173f4fb52":"Hyper FIDO Pro NFC","ba0a9266-40d8-4048-9786-d710b5474752":"YubiKey Bio Multi-protocol Edition (Consumer Profile)","3e9db280-256a-4e17-b08e-19d79e9be166":"SECORA ID V2 by Infineon Pay Edition","a7fc3f84-86a3-4da4-a3d7-eb6485a066d8":"NEOWAVE Badgeo FIDO2 (CTAP 2.1)","1f8e43df-71ff-e11d-bea3-c4ee7003b232":"Thetis Pro FIDO2 Key","3ec9c8d3-a5a7-415b-a7b5-f1d606368d3f":"YubiKey 5 CCN Series with NFC (Enterprise Profile)","9e66c661-e428-452a-a8fb-51f7ed088acf":"YubiKey 5 FIPS Series with Lightning (RC Preview)","58b44d0b-0a7c-f33a-fd48-f7153c871352":"Ledger Nano S Plus FIDO2 Authenticator","9a3f2abd-a73d-439c-9ee7-1b53a857eaa7":"YubiKey 5 Series with NFC Enhanced PIN (Enterprise Profile)","454e5346-4944-4ffd-6c93-8e9267193e9b":"Ensurity AUTH BioPro","41e39911-c669-4811-b860-c6ad0b411b96":"YubiKey 5 Series with NFC (Enterprise Profile)","146e77ef-11eb-4423-b847-ce77864e9411":"eToken Fusion NFC PIV","13ac47cf-1d78-4fd5-9060-aedaabacf826":"HID Crescendo Key V3 - Enterprise Edition","e77e3c64-05e3-428b-8824-0cbeb04b829d":"Security Key NFC by Yubico","33d6d7d0-279f-4ef3-96b3-2d3282f4bde6":"Thales eToken Fusion BIO Enterprise","8d4378b0-725d-4432-b3c2-01fcdaf46286":"VeridiumID Passkey Android SDK","7409272d-1ff9-4e10-9fc9-ac0019c124fd":"YubiKey Bio Series - FIDO Edition","bb66c294-de08-47e4-b7aa-d12c2cd3fb20":"Mettlesemi Vishwaas Hawk Authenticator using FIDO2","c4ddaf11-3032-4e77-b3b9-3a340369b9ad":"HID Crescendo Fusion","add92433-0d69-4026-8166-29b25bce64e9":"YubiKey Bio Fido Edition (Enterprise Profile)","7d1351a6-e097-4852-b8bf-c9ac5c9ce4a3":"YubiKey Bio Series - Multi-protocol Edition","07a9f89c-6407-4594-9d56-621d5f1e358b":"NXP Semiconductros FIDO2 Conformance Testing CTAP2 Authenticator","d61d3b87-3e7c-4aea-9c50-441c371903ad":"KeyVault Secp256R1 FIDO2 CTAP2 Authenticator","c62100de-759b-4bf8-b22b-63b3e3a80401":"Token Ring 3 FIDO2 Authenticator","5ca1ab1e-1337-fa57-f1d0-a117e71ca702":"Allthenticator iOS App: roaming BLE FIDO2 Allthenticator for Windows, Mac, Linux, and Allthenticate door readers","b92c3f9a-c014-4056-887f-140a2501163b":"Security Key by Yubico","54d9fee8-e621-4291-8b18-7157b99c5bec":"HID Crescendo Enabled","72a2b5b1-95a5-4df9-a881-4192aff4f72e":"GoTrust Idem Key mini","a25342c0-3cdc-4414-8e46-f4807fca511c":"YubiKey 5 Series with NFC","3a662962-c6d4-4023-bebb-98ae92e78e20":"YubiKey 5 FIPS Series with Lightning (Enterprise Profile)","20f0be98-9af9-986a-4b42-8eca4acb28e4":"Excelsecu eSecu FIDO2 Fingerprint Security Key","ca4cff1b-5a81-4404-8194-59aabcf1660b":"IDPrime 3930 FIDO","ab32f0c6-2239-afbb-c470-d2ef4e254db6":"TEST (DUMMY RECORD)","760eda36-00aa-4d29-855b-4012a182cdeb":"Security Key NFC by Yubico Preview","6028b017-b1d4-4c02-b4b3-afcdafc96bb2":"Windows Hello","b12eac35-586c-4809-a4b1-d81af6c305cf":"Deepnet SafeKey/Classic (NFC)","30b5035e-d297-4fc1-b00b-addc96ba6a97":"OneSpan FIDO Touch","560a780c-b6ae-4f03-b110-082f856425b4":"KQC QuKey Bio FIDO2 Authenticator","1ac71f64-468d-4fe0-bef1-0e5f2f551f18":"YubiKey 5 Series with NFC (Enterprise Profile)","6d44ba9b-f6ec-2e49-b930-0c8fe920cb73":"Security Key by Yubico with NFC","6832d205-75f2-44c7-a864-e868c796d06e":"Precision InnaIT Key FIDO 2 Level 2 certified","9eb85bb6-9625-4a72-815d-0487830ccab2":"Ensurity AUTH BioPro Desktop","30b5035e-d297-4ff7-010b-addc96ba6a98":"OneSpan DIGIPASS FX7-B","5ca1ab1e-fa57-1337-f1d0-a117371ca702":"Allthenticator Android App: roaming BLE FIDO2 Allthenticator for Windows, Mac, Linux, and Allthenticate door readers","eabb46cc-e241-80bf-ae9e-96fa6d2975cf":"TOKEN2 PIN Plus Security Key Series ","53414d53-554e-4700-0000-000000000000":"Samsung Pass","e416201b-afeb-41ca-a03d-2281c28322aa":"ATKey.Pro CTAP2.1","905b4cb4-ed6f-4da9-92fc-45e0d4e9b5c7":"YubiKey 5 FIPS Series (Enterprise Profile)","dee49ee1-11cb-47b6-bed0-8e995e67a0fb":"SECORA Connect SLS21 D1 FIDO 2.1 v1.0 by Infineon - Consumer Edition","cfcb13a2-244f-4b36-9077-82b79d6a7de7":"USB/NFC Passcode Authenticator","76692dc1-c56a-48d9-8e7d-31b5ced430ac":"VeriMark NFC+ USB-A Security Key","91ad6b93-264b-4987-8737-3a690cad6917":"Token Ring FIDO2 Authenticator","a02140b7-0cbd-42e1-a9b5-a39da2545114":"Feitian BioPass FIDO2 Plus (Enterprise Profile)","5753362b-4e6b-6345-7b2f-255438404c75":"WiSECURE Blentity FIDO2 Authenticator","9f77e279-a6e2-4d58-b700-31e5943c6a98":"Hyper FIDO Pro","b9f6b7b6-f929-4189-bca9-dd951240c132":"Deepnet SafeKey/Classic (USB)","cc45f64e-52a2-451b-831a-4edd8022a202":"ToothPic Passkey Provider","0bb43545-fd2c-4185-87dd-feb0b2916ace":"Security Key NFC by Yubico - Enterprise Edition","73402251-f2a8-4f03-873e-3cb6db604b03":"uTrust FIDO2 Security Key","c1f9a0bc-1dd2-404a-b27f-8e29047a43fd":"YubiKey 5 FIPS Series with NFC","70e7c36f-f2f6-9e0d-07a6-bcc243262e6b":"OneKey FIDO2 Bluetooth Authenticator","4fc84f16-2545-4e53-b8fc-7bf4d7282a10":"YubiKey 5 CCN Series with NFC (Enterprise Profile)","6ab56fad-881f-4a43-acb2-0be065924522":"YubiKey 5 Series with NFC (Enterprise Profile)","504d7149-4e4c-3841-4555-55445a677357":"WiSECURE AuthTron USB FIDO2 Authenticator","2c2aeed8-8174-4159-814b-486e92a261d0":"NEOWAVE WINKEO V2.0","f2145e86-211e-4931-b874-e22bba7d01cc":"ID-One Key","53334693-4b3f-4198-8857-53772de2ab65":"Precision InnaIT Key FIDO 2 Level 2 certified","a3975549-b191-fd67-b8fb-017e2917fdb3":"Excelsecu eSecu FIDO2 NFC Security Key","19083c3d-8383-4b18-bc03-8f1c9ab2fd1b":"YubiKey 5 Series","da1fa263-8b25-42b6-a820-c0036f21ba7f":"ATKey.Card NFC","6002f033-3c07-ce3e-d0f7-0ffe5ed42543":"Excelsecu eSecu FIDO2 Fingerprint Key","5fdb81b8-53f0-4967-a881-f5ec26fe4d18":"VinCSS FIDO2 Authenticator","78ba3993-d784-4f44-8d6e-cc0a8ad5230e":"Feitian ePass FIDO-NFC(CTAP2.1, CTAP2.0, U2F)","57f7de54-c807-4eab-b1c6-1c9be7984e92":"YubiKey 5 FIPS Series","bb405265-40cf-4115-93e5-a332c1968d8c":"ID-One Card","2d3bec26-15ee-4f5d-88b2-53622490270b":"HID Crescendo Key V2","489ff376-b48d-6640-bb69-782a860ca795":"Mettlesemi Vishwaas Eagle Authenticator using FIDO2","3b24bf49-1d45-4484-a917-13175df0867b":"YubiKey 5 Series with Lightning (Enterprise Profile)","30b5035e-d297-4ff1-010b-addc96ba6a98":"OneSpan DIGIPASS FX1a","cb69481e-8ff7-4039-93ec-0a2729a154a8":"YubiKey 5 Series","0076631b-d4a0-427f-5773-0ec71c9e0279":"HYPR FIDO2 Authenticator","d716019a-9f4e-4041-9750-17c78f8ae81a":"eToken Fusion BIO","57235694-51a5-4a4d-a81a-f42185df6502":"SHALO AUTH","24673149-6c86-42e7-98d9-433fb5b73296":"YubiKey 5 Series with Lightning","357f2718-434f-4124-8a58-7e28c5e4a2fc":"Deepnet SafeKey/Classic (NFC)","42df17de-06ba-4177-a2bb-6701be1380d6":"Feitian BioPass FIDO2 Plus Authenticator","d7a423ad-3e19-4492-9200-78137dccc136":"VivoKey Apex FIDO2","b3315166-f36c-b05f-fea8-66a3dfdad171":"Ledger Nano Gen5 FIDO2 Authenticator","ba76a271-6eb6-4171-874d-b6428dbe3437":"ATKey.ProS","97e6a830-c952-4740-95fc-7c78dc97ce47":"YubiKey Bio Series - Multi-protocol Edition (Enterprise Profile)","f573f209-b7fb-b261-671a-d7cf624cc812":"Excelsecu eSecu FIDO2 PRO+ Security Key","005b20e1-f146-4b87-8f3a-36848ff60ea6":"SECORA ID V2 by Infineon Pay Edition M","6e24d385-004a-16a0-7bfe-efd963845b34":"Ledger Stax FIDO2 Authenticator","ee882879-721c-4913-9775-3dfcce97072a":"YubiKey 5 Series","8876631b-d4a0-427f-5773-0ec71c9e0279":"Solo Secp256R1 FIDO2 CTAP2 Authenticator","fec067a1-f1d0-4c5e-b4c0-cc3237475461":"KX701 SmartToken FIDO","30b5035e-d297-4ff1-b00b-addc96ba6a98":"OneSpan DIGIPASS FX1 BIO","b267239b-954f-4041-a01b-ee4f33c145b6":"authenton1 - CTAP2.1","b50d5e0a-7f81-4959-9b12-f45407407503":"IDPrime 3940 FIDO","8c97a730-3f7b-41a6-87d6-1e9b62bda6f0":"FT-JCOS FIDO Fingerprint Card","99bf4610-ec26-4252-b31f-7380ccd59db5":"ZTPass SmartAuth","a1f52be5-dfab-4364-b51c-2bd496b14a56":"OCTATCO EzFinger2 FIDO2 AUTHENTICATOR","c3479970-e58a-4f70-836f-853bf42fb063":"YubiKey 5 Series with Lightning (Enterprise Profile)","0f00cc22-4640-41e7-9585-384ec73ffe9b":"Taglio CTAP2.1 BIO","ff4dac45-ede8-4ec2-aced-cf66103f4335":"YubiKey 5 Series","ba86dc56-635f-4141-aef6-00227b1b9af6":"TruU Windows Authenticator","3e078ffd-4c54-4586-8baa-a77da113aec5":"Hideez Key 3 FIDO2","fc5ca237-69a0-4f3c-afe4-1ebc66def6df":"Clife Key 2","ec31b4cc-2acc-4b8e-9c01-bade00ccbe26":"KeyXentic FIDO2 Secp256R1 FIDO2 CTAP2 Authenticator","5d629218-d3a5-11ed-afa1-0242ac120002":"Swissbit iShield Key Pro","bb878d7b-cf54-4784-b390-357030497043":"TruU FIDO2 Authenticator","d41f5a69-b817-4144-a13c-9ebd6d9254d6":"ATKey.Card CTAP2.0","e86addcd-7711-47e5-b42a-c18257b0bf61":"IDCore 3121 Fido","8108bdcd-8483-46b3-b3ce-359f8190325e":"SECORA ID V2 by Infineon Enterprise Edition","b113a455-cfb6-4c17-8cba-cd952feb7d48":"eToken FIDO NFC","95442b2e-f15e-4def-b270-efb106facb4e":"eWBM eFA310 FIDO2 Authenticator","dda9aa35-aaf1-4d3c-b6db-7902fd7dbbbf":"IDEMIA SOLVO Fly 80 R3 FIDO Card c","cdbdaea2-c415-5073-50f7-c04e968640b6":"Excelsecu eSecu FIDO2 Security Key","3aa78eb1-ddd8-46a8-a821-8f8ec57a7bd5":"YubiKey 5 CCN Series with NFC","bc2fe499-0d8e-4ffe-96f3-94a82840cf8c":"OCTATCO EzQuant FIDO2 AUTHENTICATOR","eb3b131e-59dc-536a-d176-cb7306da10f5":"ellipticSecure MIRkey USB Authenticator","3fd410dc-8ab7-4b86-a1cb-c7174620b2dc":"IDEMIA SOLVO Fly 80 R1 FIDO Card Draft","a6c5f5d8-2ad0-48b6-8257-e502c8970931":"eToken FIDO NFC Enterprise","e400ef8c-711d-4692-af46-7f2cf7da23ad":"Swissbit iShield Key 2 Enterprise","87c13177-85d6-40ac-8c61-fe7ab3de9dfb":"HID Crescendo Key V3","1c086528-58d5-f211-823c-356786e36140":"Atos CardOS FIDO2","77010bd7-212a-4fc9-b236-d2ca5e9d4084":"Feitian BioPass FIDO2 Authenticator","3b3faa7b-2c56-4489-bcd3-53b83ee75768":"SECORA Connect SLS21 D1 FIDO 2.1 v1.0 by Infineon - Enterprise Edition","d94a29d9-52dd-4247-9c2d-8b818b610389":"VeriMark Guard Fingerprint Key","7b96457d-e3cd-432b-9ceb-c9fdd7ef7432":"YubiKey 5 FIPS Series with Lightning","7991798a-a7f3-487f-98c0-3faf7a458a04":"HID Crescendo Key V3","833b721a-ff5f-4d00-bb2e-bdda3ec01e29":"Feitian ePass FIDO2 Authenticator","c89674e3-a765-4b07-888a-7c086fbdf04b":"StarSign FIDO Card","a11a5faa-9f32-4b8c-8c5d-2f7d13e8c942":"AliasVault","ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4":"Google Password Manager","adce0002-35bc-c60a-648b-0b25f1f05503":"Chrome on Mac","dd4ec289-e01d-41c9-bb89-70fa845d4bf2":"iCloud Keychain (Managed)","531126d6-e717-415c-9320-3d9aa6981239":"Dashlane","bada5566-a7aa-401f-bd96-45619a55120d":"1Password","b84e4048-15dc-4dd0-8640-f4f60813c8af":"NordPass","0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6":"Keeper","891494da-2c90-4d31-a9cd-4eab0aed1309":"Sésame","f3809540-7f14-49c1-a8b3-8f813b225541":"Enpass","b5397666-4885-aa6b-cebf-e52262a439a2":"Chromium Browser","771b48fd-d3d4-4f74-9232-fc157ab0507a":"Edge on Mac","d548826e-79b4-db40-a3d8-11116f7e8349":"Bitwarden","fbfc3007-154e-4ecc-8c0b-6e020557d7bd":"Apple Passwords","66a0ccb3-bd6a-191f-ee06-e375c50b9846":"Thales Bio iOS SDK","8836336a-f590-0921-301d-46427531eee6":"Thales Bio Android SDK","cd69adb5-3c7a-deb9-3177-6800ea6cb72a":"Thales PIN Android SDK","17290f1e-c212-34d0-1423-365d729f09d9":"Thales PIN iOS SDK","50726f74-6f6e-5061-7373-50726f746f6e":"Proton Pass","fdb141b2-5d84-443e-8a35-4698c205a502":"KeePassXC","eaecdef2-1c31-5634-8639-f1cbd9c00a08":"KeePassDX","9addb28c-b46f-4402-808f-019651441ff3":"KeePassPasskey","bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0":"iPasswords","b35a26b2-8f6e-4697-ab1d-d44db4da28c6":"Zoho Vault","b78a0a55-6ef8-d246-a042-ba0f6d55050c":"LastPass","de503f9c-21a4-4f76-b4b7-558eb55c6f89":"Devolutions","22248c4c-7a12-46e2-9a41-44291b373a4d":"LogMeOnce","a10c6dd9-465e-4226-8198-c7c44b91c555":"Kaspersky Password Manager","d350af52-0351-4ba2-acd3-dfeeadc3f764":"pwSafe","d3452668-01fd-4c12-926c-83a4204853aa":"Microsoft Password Manager","6d212b28-a2c1-4638-b375-5932070f62e9":"initial","d49b2120-b865-4191-8cea-be84a52b0485":"Heimlane Vault","d9be9d39-e6a6-4c28-a581-32b044d986e4":"Sticky Password Manager","70617373-7761-6c6c-6669-646f32303236":"Passwall","c9cadfc9-89a9-489e-a25a-c7e86a4d5f15":"Burp Suite Navigation Recorder","fa37f553-f9b6-4adb-ac53-8bbb57ebdf0d":"Norton Password Manager","a4a2d88e-9796-4356-9164-e2a5a8bd019c":"Avast Password Manager","e7db2bd3-f2fe-4d71-ad78-7e7aa166cfd1":"Avira Password Manager","6bb49926-160a-4306-a100-4eb39ba6ac45":"AVG Password Manager","da583154-ce16-4cdf-9fe6-1dba788c0998":"Hey Be Safe","d2717a32-9851-48a8-9961-b264c97a411a":"Fenko Vault"} From 2cfbcb4b67d194ddc378199b4fa0bdc1e81c00a1 Mon Sep 17 00:00:00 2001 From: "Alessandro (Ale) Segala" <43508+ItalyPaleAle@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:48:05 -1000 Subject: [PATCH 5/7] refactor: use actors for db configuration (#1604) Co-authored-by: Claude --- backend/go.mod | 2 +- backend/go.sum | 4 +- backend/internal/appconfig/appconfig_actor.go | 200 ++++++++ backend/internal/appconfig/migration.go | 97 ++++ backend/internal/appconfig/model.go | 272 ++++++++++ backend/internal/appconfig/model_test.go | 225 +++++++++ backend/internal/appconfig/service.go | 210 ++++++++ backend/internal/appconfig/service_test.go | 344 +++++++++++++ backend/internal/appconfig/testing_unit.go | 27 + backend/internal/bootstrap/bootstrap.go | 2 +- .../internal/bootstrap/router_bootstrap.go | 4 +- .../internal/bootstrap/services_bootstrap.go | 40 +- .../internal/cmds/encryption_key_rotate.go | 2 +- .../cmds/encryption_key_rotate_test.go | 2 +- backend/internal/common/internal_errors.go | 20 + .../controller/app_config_controller.go | 35 +- .../internal/controller/user_controller.go | 69 ++- .../controller/user_group_controller.go | 22 +- .../internal/instanceid/instanceid_test.go | 18 +- backend/internal/job/api_key_expiry_job.go | 14 +- backend/internal/job/ldap_job.go | 15 +- .../middleware/auth_middleware_test.go | 9 +- .../internal/middleware/rate_limit_test.go | 1 + backend/internal/model/app_config.go | 203 -------- backend/internal/model/app_config_test.go | 126 ----- .../internal/service/app_config_service.go | 416 --------------- .../service/app_config_service_test.go | 473 ------------------ backend/internal/service/audit_log_service.go | 23 +- backend/internal/service/e2etest_service.go | 79 ++- backend/internal/service/email_service.go | 45 +- .../internal/service/import_service_test.go | 6 +- backend/internal/service/jwt_service.go | 25 +- backend/internal/service/jwt_service_test.go | 65 +-- backend/internal/service/ldap_service.go | 154 +++--- backend/internal/service/ldap_service_test.go | 74 +-- backend/internal/service/oidc_service.go | 23 +- backend/internal/service/oidc_service_test.go | 6 +- .../service/one_time_access_service.go | 63 +-- .../service/one_time_access_service_test.go | 10 +- .../internal/service/user_group_service.go | 35 +- backend/internal/service/user_service.go | 66 +-- backend/internal/service/user_service_test.go | 25 +- backend/internal/usersignup/handler.go | 25 +- backend/internal/usersignup/module.go | 15 +- backend/internal/usersignup/service.go | 20 +- backend/internal/webauthn/handler.go | 23 +- backend/internal/webauthn/module.go | 12 +- backend/internal/webauthn/service.go | 40 +- backend/internal/webauthn/service_test.go | 21 +- .../20260718000000_freeze_config.down.sql | 16 + .../20260718000000_freeze_config.up.sql | 12 + .../20260718000000_freeze_config.down.sql | 22 + .../20260718000000_freeze_config.up.sql | 18 + 53 files changed, 2061 insertions(+), 1714 deletions(-) create mode 100644 backend/internal/appconfig/appconfig_actor.go create mode 100644 backend/internal/appconfig/migration.go create mode 100644 backend/internal/appconfig/model.go create mode 100644 backend/internal/appconfig/model_test.go create mode 100644 backend/internal/appconfig/service.go create mode 100644 backend/internal/appconfig/service_test.go create mode 100644 backend/internal/appconfig/testing_unit.go create mode 100644 backend/internal/common/internal_errors.go delete mode 100644 backend/internal/model/app_config.go delete mode 100644 backend/internal/model/app_config_test.go delete mode 100644 backend/internal/service/app_config_service.go delete mode 100644 backend/internal/service/app_config_service_test.go create mode 100644 backend/resources/migrations/postgres/20260718000000_freeze_config.down.sql create mode 100644 backend/resources/migrations/postgres/20260718000000_freeze_config.up.sql create mode 100644 backend/resources/migrations/sqlite/20260718000000_freeze_config.down.sql create mode 100644 backend/resources/migrations/sqlite/20260718000000_freeze_config.up.sql diff --git a/backend/go.mod b/backend/go.mod index 522c38a3..fa3d4e29 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,7 +24,7 @@ require ( github.com/go-webauthn/webauthn v0.17.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 - github.com/italypaleale/francis v0.1.0-beta.10 + github.com/italypaleale/francis v0.1.0-beta.11 github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be github.com/italypaleale/go-sql-utils v0.2.4 github.com/jackc/pgx/v5 v5.10.0 diff --git a/backend/go.sum b/backend/go.sum index 1c7844e2..101b5802 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -263,8 +263,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/italypaleale/francis v0.1.0-beta.10 h1:LCYVwkZAkakv7g5ZS6TVIRH7hozr1A0eg24LXWWBJYE= -github.com/italypaleale/francis v0.1.0-beta.10/go.mod h1:vqKhwdLs5Sx+n6JCNknEKAODtEU51E9/LC1q9JAG3zk= +github.com/italypaleale/francis v0.1.0-beta.11 h1:FurXV2vMkRzJRFldQ6Z/bhLSJz8YXHm84uiASGeyWWU= +github.com/italypaleale/francis v0.1.0-beta.11/go.mod h1:vqKhwdLs5Sx+n6JCNknEKAODtEU51E9/LC1q9JAG3zk= github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be h1:jgu+Mdsda++LqPxz8cj8vvgiFINQ8PhFB4Q1VZpyPjs= github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY= github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c= diff --git a/backend/internal/appconfig/appconfig_actor.go b/backend/internal/appconfig/appconfig_actor.go new file mode 100644 index 00000000..ab678991 --- /dev/null +++ b/backend/internal/appconfig/appconfig_actor.go @@ -0,0 +1,200 @@ +package appconfig + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/italypaleale/francis/actor" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" +) + +// The AppConfig singleton actor maintains the dynamic configuration for the Pocket ID cluster +// Instances of Pocket ID should bootstrap the AppConfig's actor upon startup to ensure the config is loaded (and migrated if needed) +// After startup, Peek can be used for read-only operations such as retrieving the config or listing it + +// AppConfigActorType is the actor type for the AppConfig actor +const AppConfigActorType = "AppConfig" + +// appConfigActor is a singleton actor that manages the dynamic app configuration +type appConfigActor struct { + log *slog.Logger + client actor.Client[*AppConfigModel] +} + +// appConfigActorBootstrap is the type for the payload of the init method +type appConfigActorBootstrap struct { + LegacyConfig map[string]string +} + +// NewAppConfigActor allocates a new AppConfig actor +// It satisfies actor.Factory +func NewAppConfigActor(actorID string, service *actor.Service) actor.Actor { + log := slog. + With( + slog.String("scope", "actor"), + slog.String("actorType", AppConfigActorType), + slog.String("actorID", actorID), + ) + + log.Info("AppConfig actor created") + + return &appConfigActor{ + log: log, + client: actor.NewActorClient[*AppConfigModel](AppConfigActorType, actorID, service), + } +} + +// Bootstrap implements actor.ActorBootstrapper for the singleton actor +func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error { + // Load the actor state + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + state, err := a.client.GetState(ctx) + if err != nil { + return fmt.Errorf("error retrieving actor state: %w", err) + } + + // If we already have a state, nothing else to do + if state != nil { + return nil + } + + // Check if the request data contains legacy config to init from + if data != nil { + payload := appConfigActorBootstrap{} + err = data.Decode(&payload) + if err != nil { + return fmt.Errorf("request body is not valid for method 'init': %w", err) + } + + if len(payload.LegacyConfig) > 0 { + state, err = fromLegacyConfig(payload.LegacyConfig) + if err != nil { + return fmt.Errorf("request body is not valid for method 'init': LegacyConfig property could not be parsed: %w", err) + } + } + } + + // If we still have no state, generate a new default config + if state == nil { + state = getDefaultConfig() + } + + // Save the updated state + ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + err = a.client.SetState(ctx, state, nil) + if err != nil { + return fmt.Errorf("error saving actor state: %w", err) + } + + return nil +} + +func (a *appConfigActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) { + // Only supported method is "get" + if method != "get" { + return nil, common.ErrUnsupportedActorMethod{Method: method} + } + + // Load the actor state + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + state, err := a.client.GetState(ctx) + if err != nil { + return nil, fmt.Errorf("error retrieving actor state: %w", err) + } + + // Return the state + return state, nil +} + +func (a *appConfigActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) { + // Check the method first + switch method { + case "get", "update", "replace": + // All good + // Note: we support "get" also via Invoke and not just Peek + default: + return nil, common.ErrUnsupportedActorMethod{Method: method} + } + + // Load the actor state + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + state, err := a.client.GetState(ctx) + if err != nil { + return nil, fmt.Errorf("error retrieving actor state: %w", err) + } + + switch method { + case "get": + // If the method is "get", just return the actor state, we're done + // This switch case is a no-op + + case "replace": + // Replace the entire config + // The input data must be a dto.AppConfigUpdateDto + if data == nil { + return nil, errors.New("request body is empty for method 'replace'") + } + payload := dto.AppConfigUpdateDto{} + err = data.Decode(&payload) + if err != nil { + return nil, fmt.Errorf("request body is not valid for method 'replace': %w", err) + } + + // Update the in-memory data + // Work on a clone to avoid touching the cached object in case of errors + newState := state.Clone() + newState.Replace(payload) + + // Save the updated state, which also updates the cached object + ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + err = a.client.SetState(ctx, newState, nil) + if err != nil { + return nil, fmt.Errorf("error saving actor state: %w", err) + } + + return newState, nil + + case "update": + // Update the config + // The input data must be a map[string]string + if data == nil { + return nil, errors.New("request body is empty for method 'update'") + } + payload := map[string]string{} + err = data.Decode(&payload) + if err != nil { + return nil, fmt.Errorf("request body is not valid for method 'update': %w", err) + } + + // Update the in-memory data + // Work on a clone to avoid touching the cached object in case of errors + newState := state.Clone() + err = newState.Update(payload) + if err != nil { + return nil, fmt.Errorf("request body is not valid for method 'update': %w", err) + } + + // Save the updated state, which also updates the cached object + ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + err = a.client.SetState(ctx, newState, nil) + if err != nil { + return nil, fmt.Errorf("error saving actor state: %w", err) + } + + return newState, nil + } + + // Return the state + return state, nil +} diff --git a/backend/internal/appconfig/migration.go b/backend/internal/appconfig/migration.go new file mode 100644 index 00000000..0b2086ae --- /dev/null +++ b/backend/internal/appconfig/migration.go @@ -0,0 +1,97 @@ +package appconfig + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + "github.com/italypaleale/go-kit/utils" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +// This file holds the one-time migration of the legacy (pre-actor) app config +// The legacy config was stored in the "config_migrated" key of the kv table, and it's loaded here to bootstrap the AppConfig actor on first startup + +// LoadLegacyConfig loads the legacy config from the database +// This was migrated to the "config_migrated" key in the kv table +func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) { + // Retrieve the migrated config from the kv table + row := model.KV{ + Key: "config_migrated", + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + err := db.WithContext(ctx).First(&row).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + // There's no migrated config in the database, nothing to do + return nil, nil + case err != nil: + return nil, fmt.Errorf("failed to load migrated config from the database: %w", err) + case row.Value == nil || len(*row.Value) == 0: + // Also no migrated config, nothing to do + return nil, nil + } + + // The value is a JSON-encoded dictionary + res := map[string]string{} + err = json.Unmarshal([]byte(*row.Value), &res) + if err != nil { + return nil, fmt.Errorf("error parsing migrated config: %w", err) + } + + if len(res) == 0 { + return nil, nil + } + return res, nil +} + +// fromLegacyConfig builds an appConfigModel from a legacy config map +// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type +// Keys that are missing (or have an empty value) retain the default value +func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) { + // Start from the default configuration, then override with the values from the legacy config + dest := getDefaultConfig() + + rt := reflect.ValueOf(dest).Elem().Type() + rv := reflect.ValueOf(dest).Elem() + for i := range rt.NumField() { + field := rt.Field(i) + + // Get the value of the json tag, taking only what's before the comma + key, _, _ := strings.Cut(field.Tag.Get("json"), ",") + + // Look up the value in the legacy config + // If the key is missing or the value is empty, we keep the default value + value, ok := legacyCfg[key] + if !ok || value == "" { + continue + } + + // Cast the string value to the field's type + fv := rv.Field(i) + switch fv.Kind() { //nolint:exhaustive + case reflect.String: + fv.SetString(value) + case reflect.Bool: + fv.SetBool(utils.IsTruthy(value)) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err) + } + fv.SetInt(n) + default: + return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key) + } + } + + return dest, nil +} diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go new file mode 100644 index 00000000..47436522 --- /dev/null +++ b/backend/internal/appconfig/model.go @@ -0,0 +1,272 @@ +package appconfig + +import ( + "errors" + "reflect" + "strconv" + "strings" + "time" + + "github.com/italypaleale/go-kit/utils" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" +) + +type AppConfigModel struct { + // General + AppName AppConfigValue `json:"appName" public:"true"` + SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes + HomePageURL AppConfigValue `json:"homePageUrl" public:"true"` + EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"` + AccentColor AppConfigValue `json:"accentColor" public:"true"` + DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"` + AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"` + AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"` + + SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings + SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string} + // Email + RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"` + SmtpHost AppConfigValue `json:"smtpHost"` + SmtpPort AppConfigValue `json:"smtpPort"` + SmtpFrom AppConfigValue `json:"smtpFrom"` + SmtpUser AppConfigValue `json:"smtpUser"` + SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"` + SmtpTls AppConfigValue `json:"smtpTls"` + SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"` + EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"` + EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"` + EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"` + EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"` + EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"` + // LDAP + LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"` + LdapUrl AppConfigValue `json:"ldapUrl"` + LdapBindDn AppConfigValue `json:"ldapBindDn"` + LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"` + LdapBase AppConfigValue `json:"ldapBase"` + LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"` + LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"` + LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"` + LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"` + LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"` + LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"` + LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"` + LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"` + LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"` + LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"` + LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"` + LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"` + LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"` + LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"` + LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"` +} + +// Clone returns a deep copy of the AppConfigModel. +func (m *AppConfigModel) Clone() *AppConfigModel { + if m == nil { + return nil + } + + // All fields are value types (AppConfigValue is a string), so copying the struct is sufficient for a deep copy. + clone := *m + return &clone +} + +// AppConfigValue holds a value +type AppConfigValue string + +// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc. +func (a AppConfigValue) IsTrue() bool { + return utils.IsTruthy(string(a)) +} + +// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes. +func (a AppConfigValue) AsDurationMinutes() time.Duration { + val, err := strconv.Atoi(string(a)) + if err != nil { + return 0 + } + return time.Duration(val) * time.Minute +} + +// String implements fmt.Stringer +func (a AppConfigValue) String() string { + return string(a) +} + +func getDefaultConfig() *AppConfigModel { + // Values are the default ones + return &AppConfigModel{ + // General + AppName: "Pocket ID", + SessionDuration: "60", + HomePageURL: "/settings/account", + EmailsVerified: "false", + DisableAnimations: "false", + AllowOwnAccountEdit: "true", + AllowUserSignups: "disabled", + SignupDefaultUserGroupIDs: "[]", + SignupDefaultCustomClaims: "[]", + AccentColor: "default", + // Email + RequireUserEmail: "true", + SmtpHost: "", + SmtpPort: "", + SmtpFrom: "", + SmtpUser: "", + SmtpPassword: "", + SmtpTls: "none", + SmtpSkipCertVerify: "false", + EmailLoginNotificationEnabled: "false", + EmailOneTimeAccessAsUnauthenticatedEnabled: "false", + EmailOneTimeAccessAsAdminEnabled: "false", + EmailApiKeyExpirationEnabled: "false", + EmailVerificationEnabled: "false", + // LDAP + LdapEnabled: "false", + LdapUrl: "", + LdapBindDn: "", + LdapBindPassword: "", + LdapBase: "", + LdapUserSearchFilter: "(objectClass=person)", + LdapUserGroupSearchFilter: "(objectClass=groupOfNames)", + LdapSkipCertVerify: "false", + LdapAttributeUserUniqueIdentifier: "", + LdapAttributeUserUsername: "", + LdapAttributeUserEmail: "", + LdapAttributeUserFirstName: "", + LdapAttributeUserLastName: "", + LdapAttributeUserDisplayName: "cn", + LdapAttributeUserProfilePicture: "", + LdapAttributeGroupMember: "member", + LdapAttributeGroupUniqueIdentifier: "", + LdapAttributeGroupName: "", + LdapAdminGroupName: "", + LdapSoftDeleteUsers: "true", + } +} + +// Replace updates every configuration property with the values from the input DTO +// An empty string value resets the corresponding property to its default value +func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) { + // Collect the values from the input DTO into a map, keyed by the "json" tag + inRv := reflect.ValueOf(input) + inRt := inRv.Type() + values := make(map[string]string, inRt.NumField()) + for i := range inRt.NumField() { + // Get the value of the json tag, taking only what's before the comma + key, _, _ := strings.Cut(inRt.Field(i).Tag.Get("json"), ",") + values[key] = inRv.Field(i).String() + } + + // Iterate through all the properties, setting each one from the input + // Properties that are missing from the input or have an empty value are reset to their default + defaults := reflect.ValueOf(getDefaultConfig()).Elem() + rv := reflect.ValueOf(m).Elem() + rt := rv.Type() + for i := range rt.NumField() { + key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",") + + value, ok := values[key] + if !ok || value == "" { + value = defaults.Field(i).String() + } + + rv.Field(i).SetString(value) + } +} + +// Update sets configuration properties from the provided key-value pairs +// Keys correspond to the "json" tags on the model +// An empty string value resets the property to its default value +func (m *AppConfigModel) Update(values map[string]string) error { + rv := reflect.ValueOf(m).Elem() + rt := rv.Type() + defaults := reflect.ValueOf(getDefaultConfig()).Elem() + + // Iterate through the key-value pairs + for key, value := range values { + // Find the field in the struct whose "json" tag matches + fieldIdx := -1 + for j := range rt.NumField() { + // Separate the key (before the comma) from any optional attributes after + tagValue, _, _ := strings.Cut(rt.Field(j).Tag.Get("json"), ",") + if tagValue == key { + fieldIdx = j + break + } + } + if fieldIdx < 0 { + return AppConfigKeyNotFoundError{field: key} + } + + // An empty string means we use the default value for the property + if value == "" { + value = defaults.Field(fieldIdx).String() + } + + rv.Field(fieldIdx).SetString(value) + } + + return nil +} + +// AppConfigVariable is a single application configuration property, as a key/value pair +type AppConfigVariable struct { + Key string + Value string +} + +// ToAppConfigVariableSlice returns the configuration as a slice of key/value pairs +// If showAll is false, only properties marked as public are included +// If redactSensitiveValues is true, sensitive values are redacted when the UI config is disabled +func (m *AppConfigModel) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable { + // Iterate through all fields + cfgValue := reflect.ValueOf(m).Elem() + cfgType := cfgValue.Type() + + res := make([]AppConfigVariable, 0, cfgType.NumField()) + for i := range cfgType.NumField() { + field := cfgType.Field(i) + + key, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if key == "" { + continue + } + + // If we're only showing public variables and this is not public, skip it + if !showAll && field.Tag.Get("public") != "true" { + continue + } + + value := cfgValue.Field(i).String() + + // Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true + if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && field.Tag.Get("sensitive") == "true" { + value = "XXXXXXXXXX" + } + + res = append(res, AppConfigVariable{ + Key: key, + Value: value, + }) + } + + return res +} + +type AppConfigKeyNotFoundError struct { + field string +} + +func (e AppConfigKeyNotFoundError) Error() string { + return "cannot find config key '" + e.field + "'" +} + +func (e AppConfigKeyNotFoundError) Is(target error) bool { + // Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError + _, ok := errors.AsType[*AppConfigKeyNotFoundError](target) + return ok +} diff --git a/backend/internal/appconfig/model_test.go b/backend/internal/appconfig/model_test.go new file mode 100644 index 00000000..961760f3 --- /dev/null +++ b/backend/internal/appconfig/model_test.go @@ -0,0 +1,225 @@ +package appconfig + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/dto" +) + +// dtoWithMarkerValues returns a DTO where every field is set to a unique, non-empty marker derived from its "json" key, so we can assert each value lands in the right place. +func dtoWithMarkerValues() dto.AppConfigUpdateDto { + var input dto.AppConfigUpdateDto + rv := reflect.ValueOf(&input).Elem() + rt := rv.Type() + for i := range rt.NumField() { + key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",") + rv.Field(i).SetString("marker-" + key) + } + return input +} + +func TestAppConfigModel_Replace(t *testing.T) { + t.Run("populates every property from the DTO", func(t *testing.T) { + input := dtoWithMarkerValues() + + var m AppConfigModel + m.Replace(input) + + // Each model property must hold the marker built from its own "json" key. + // This also asserts that the model and the DTO share the same set of keys. + rv := reflect.ValueOf(&m).Elem() + rt := rv.Type() + for i := range rt.NumField() { + key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",") + assert.Equalf(t, "marker-"+key, rv.Field(i).String(), "property %s (key %q)", rt.Field(i).Name, key) + } + }) + + t.Run("empty values fall back to their default", func(t *testing.T) { + defaults := getDefaultConfig() + + // Start from all-markers, then blank out a few properties whose default is non-empty + input := dtoWithMarkerValues() + input.AppName = "" + input.SessionDuration = "" + input.SmtpTls = "" + input.LdapUserSearchFilter = "" + + var m AppConfigModel + m.Replace(input) + + // Blanked properties are reset to their default + assert.Equal(t, defaults.AppName, m.AppName) + assert.Equal(t, defaults.SessionDuration, m.SessionDuration) + assert.Equal(t, defaults.SmtpTls, m.SmtpTls) + assert.Equal(t, defaults.LdapUserSearchFilter, m.LdapUserSearchFilter) + + // A property that was provided keeps the provided value + assert.Equal(t, AppConfigValue("marker-homePageUrl"), m.HomePageURL) + }) + + t.Run("an empty DTO resets every property to its default", func(t *testing.T) { + // Pre-populate with junk to prove Replace overwrites existing state + m := AppConfigModel{ + AppName: "Custom Name", + LdapEnabled: "true", + SmtpHost: "smtp.example.com", + } + + m.Replace(dto.AppConfigUpdateDto{}) + + assert.Equal(t, *getDefaultConfig(), m) + }) + + t.Run("provided values overwrite existing non-default values", func(t *testing.T) { + m := getDefaultConfig() + m.AppName = "Old Name" + m.LdapEnabled = "true" + + input := dto.AppConfigUpdateDto{} + input.AppName = "New Name" + + m.Replace(input) + + // Explicitly provided value wins + assert.Equal(t, AppConfigValue("New Name"), m.AppName) + // Everything else in the DTO was empty, so it is reset to the default + assert.Equal(t, getDefaultConfig().LdapEnabled, m.LdapEnabled) + }) + + t.Run("stores raw string values without type coercion", func(t *testing.T) { + input := dto.AppConfigUpdateDto{} + input.SessionDuration = "120" // int-tagged property + input.LdapEnabled = "true" // bool-tagged property + + var m AppConfigModel + m.Replace(input) + + assert.Equal(t, AppConfigValue("120"), m.SessionDuration) + assert.Equal(t, AppConfigValue("true"), m.LdapEnabled) + }) +} + +func TestAppConfigModel_Clone(t *testing.T) { + t.Run("clones every property", func(t *testing.T) { + // Populate every property with a unique marker so we can assert each one is copied + var original AppConfigModel + rv := reflect.ValueOf(&original).Elem() + rt := rv.Type() + for i := range rt.NumField() { + key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",") + rv.Field(i).SetString("marker-" + key) + } + + clone := original.Clone() + + require.NotNil(t, clone) + // The clone must be a distinct object with equal contents + assert.NotSame(t, &original, clone) + assert.Equal(t, original, *clone) + }) + + t.Run("mutating the clone does not affect the original", func(t *testing.T) { + original := getDefaultConfig() + + clone := original.Clone() + clone.AppName = "Changed" + clone.LdapEnabled = "true" + + // The original keeps its values + assert.Equal(t, getDefaultConfig().AppName, original.AppName) + assert.Equal(t, getDefaultConfig().LdapEnabled, original.LdapEnabled) + + // The clone holds the new values + assert.Equal(t, AppConfigValue("Changed"), clone.AppName) + assert.Equal(t, AppConfigValue("true"), clone.LdapEnabled) + }) + + t.Run("mutating the original does not affect the clone", func(t *testing.T) { + original := getDefaultConfig() + + clone := original.Clone() + original.AppName = "Changed" + + assert.Equal(t, getDefaultConfig().AppName, clone.AppName) + }) + + t.Run("cloning a nil receiver returns nil", func(t *testing.T) { + var m *AppConfigModel + assert.Nil(t, m.Clone()) + }) +} + +func TestAppConfigModel_Update(t *testing.T) { + t.Run("updates a single property", func(t *testing.T) { + m := getDefaultConfig() + + err := m.Update(map[string]string{"appName": "My App"}) + require.NoError(t, err) + + assert.Equal(t, AppConfigValue("My App"), m.AppName) + }) + + t.Run("updates multiple properties and leaves others untouched", func(t *testing.T) { + m := getDefaultConfig() + + err := m.Update(map[string]string{"appName": "My App", "homePageUrl": "/home", "ldapEnabled": "true"}) + require.NoError(t, err) + + assert.Equal(t, AppConfigValue("My App"), m.AppName) + assert.Equal(t, AppConfigValue("/home"), m.HomePageURL) + assert.Equal(t, AppConfigValue("true"), m.LdapEnabled) + // A property that was not part of the update keeps its previous value + assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration) + }) + + t.Run("an empty value resets the property to its default", func(t *testing.T) { + m := getDefaultConfig() + m.SmtpTls = "tls" // default is "none" + m.SessionDuration = "120" // default is "60" + + err := m.Update(map[string]string{"smtpTls": "", "sessionDuration": ""}) + require.NoError(t, err) + + assert.Equal(t, getDefaultConfig().SmtpTls, m.SmtpTls) + assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration) + }) + + t.Run("stores raw string values without type coercion", func(t *testing.T) { + m := getDefaultConfig() + + err := m.Update(map[string]string{"sessionDuration": "120", "disableAnimations": "true"}) + require.NoError(t, err) + + assert.Equal(t, AppConfigValue("120"), m.SessionDuration) + assert.Equal(t, AppConfigValue("true"), m.DisableAnimations) + }) + + t.Run("an empty map is a no-op", func(t *testing.T) { + m := getDefaultConfig() + before := *m + + err := m.Update(nil) + require.NoError(t, err) + + assert.Equal(t, before, *m) + }) + + t.Run("an unknown key returns AppConfigKeyNotFoundError", func(t *testing.T) { + m := getDefaultConfig() + + err := m.Update(map[string]string{"thisKeyDoesNotExist": "value"}) + require.Error(t, err) + require.EqualError(t, err, "cannot find config key 'thisKeyDoesNotExist'") + + notFound, ok := errors.AsType[AppConfigKeyNotFoundError](err) + require.True(t, ok) + assert.Equal(t, "thisKeyDoesNotExist", notFound.field) + }) +} diff --git a/backend/internal/appconfig/service.go b/backend/internal/appconfig/service.go new file mode 100644 index 00000000..3eb92f4a --- /dev/null +++ b/backend/internal/appconfig/service.go @@ -0,0 +1,210 @@ +package appconfig + +import ( + "context" + "errors" + "fmt" + "os" + "reflect" + "strings" + "time" + + "github.com/italypaleale/francis/actor" + "github.com/italypaleale/francis/host/local" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/tracing" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +type AppConfigService struct { + actSvc *actor.Service + envConfig *AppConfigModel +} + +func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) { + service = &AppConfigService{} + + // If the UI config is disabled, we do not need to init the config actor + if common.EnvConfig.UiConfigDisabled { + service.envConfig, err = service.loadDbConfigFromEnv() + if err != nil { + return nil, fmt.Errorf("error loading app config from the env: %w", err) + } + + return service, nil + } + + // Note: we need to assign to the "err" variable in this method (for tracing), do not inline this into the "if" + ctx, span := tracing.Start(ctx, "pocketid.appconfig.init") + defer tracing.End(span, err) + + // Load the legacy config if any, which we need to send to the actor as bootstrap data + legacyCfg, err := LoadLegacyConfig(ctx, db) + if err != nil { + return nil, fmt.Errorf("error loading legacy config: %w", err) + } + + // Register the AppConfig actor + // This is a singleton actor and it's bootstrapped with the legacy config if present + bootstrapData := &appConfigActorBootstrap{ + LegacyConfig: legacyCfg, + } + err = actors.RegisterSingletonActor( + AppConfigActorType, NewAppConfigActor, + local.WithBootstrapData(bootstrapData), + local.WithIdleTimeout(-1), // Disable idle timeout for this actor + ) + if err != nil { + return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err) + } + + service.actSvc = actors.Service() + + return service, nil +} + +// GetConfig returns the application configuration +// Important: Treat the object as read-only: do not modify its properties directly! +func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel, error) { + // If the UI config is disabled, only load from the env + if common.EnvConfig.UiConfigDisabled { + return s.envConfig, nil + } + + // Retrieve the config from the actor + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + res, err := s.actSvc.Peek(ctx, AppConfigActorType, actor.SingletonActorID, "get", nil) + if err != nil { + return nil, fmt.Errorf("error retrieving config from actor: %w", err) + } + if res == nil { + return nil, errors.New("config actor response was empty") + } + + var cfg AppConfigModel + err = res.Decode(&cfg) + if err != nil { + return nil, fmt.Errorf("error decoding config actor response: %w", err) + } + + return &cfg, nil +} + +// UpdateAppConfig replaces the entire application configuration with the values from the input DTO. +func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]AppConfigVariable, error) { + // If the UI config is disabled, we cannot continue + if common.EnvConfig.UiConfigDisabled { + return nil, &common.UiConfigDisabledError{} + } + + // Replace the entire config by invoking the actor + cfg, err := s.invokeConfigActor(ctx, "replace", input) + if err != nil { + return nil, err + } + + // Return the updated config + return cfg.ToAppConfigVariableSlice(true, false), nil +} + +// UpdateAppConfigValues updates the provided application configuration values. +// Keys correspond to the "json" tags on the config model. +// An empty string value resets the property to its default value. +func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error { + // Count of keysAndValues must be even + if len(keysAndValues)%2 != 0 { + return errors.New("invalid number of arguments received") + } + + // If the UI config is disabled, we cannot continue + if common.EnvConfig.UiConfigDisabled { + return &common.UiConfigDisabledError{} + } + + // Collect the key-value pairs into a map for the actor + // (Note the += 2, as we are iterating through key-value pairs) + values := make(map[string]string, len(keysAndValues)/2) + for i := 1; i < len(keysAndValues); i += 2 { + values[keysAndValues[i-1]] = keysAndValues[i] + } + + // Update the config by invoking the actor + _, err := s.invokeConfigActor(ctx, "update", values) + return err +} + +// ListAppConfig returns the application configuration as a slice of key/value pairs. +// If showAll is false, only properties marked as public are included. +func (s *AppConfigService) ListAppConfig(ctx context.Context, showAll bool) ([]AppConfigVariable, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + + return cfg.ToAppConfigVariableSlice(showAll, true), nil +} + +// invokeConfigActor invokes a method on the AppConfig actor and decodes the returned state. +func (s *AppConfigService) invokeConfigActor(parentCtx context.Context, method string, data any) (*AppConfigModel, error) { + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + res, err := s.actSvc.Invoke(ctx, AppConfigActorType, actor.SingletonActorID, method, data) + if err != nil { + return nil, fmt.Errorf("error invoking config actor method '%s': %w", method, err) + } + if res == nil { + return nil, errors.New("config actor response was empty") + } + + var cfg AppConfigModel + err = res.Decode(&cfg) + if err != nil { + return nil, fmt.Errorf("error decoding config actor response: %w", err) + } + + return &cfg, nil +} + +func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) { + // First, start from the default configuration + dest := getDefaultConfig() + + // Iterate through each field + rt := reflect.ValueOf(dest).Elem().Type() + rv := reflect.ValueOf(dest).Elem() + for i := range rt.NumField() { + field := rt.Field(i) + + // Derive the environment variable name from the configuration's JSON key + key, _, _ := strings.Cut(field.Tag.Get("json"), ",") + envVarName := utils.CamelCaseToScreamingSnakeCase(key) + + // Set the value if it's set + value, ok := os.LookupEnv(envVarName) + if ok { + rv.Field(i).SetString(value) + continue + } + + // If it's sensitive, we also allow reading from file + if field.Tag.Get("sensitive") == "true" { + fileName := os.Getenv(envVarName + "_FILE") + if fileName != "" { + // #nosec G703 - Value is provided by admin + b, err := os.ReadFile(fileName) + if err != nil { + return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err) + } + + rv.Field(i).SetString(string(b)) + continue + } + } + } + + return dest, nil +} diff --git a/backend/internal/appconfig/service_test.go b/backend/internal/appconfig/service_test.go new file mode 100644 index 00000000..1e3b08ad --- /dev/null +++ b/backend/internal/appconfig/service_test.go @@ -0,0 +1,344 @@ +package appconfig + +import ( + "encoding/json" + "testing" + "time" + + "github.com/italypaleale/francis/host/local" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +// setUIConfigDisabled sets common.EnvConfig.UiConfigDisabled for the duration of the test, restoring the previous global afterwards +func setUIConfigDisabled(t *testing.T, disabled bool) { + t.Helper() + + original := common.EnvConfig + t.Cleanup(func() { + common.EnvConfig = original + }) + common.EnvConfig.UiConfigDisabled = disabled +} + +// newActorBackedService creates an AppConfigService wired to an in-memory test actor host. +// The AppConfig singleton actor is registered and bootstrapped from db, which is also used to load any legacy config. +func newActorBackedService(t *testing.T, db *gorm.DB) *AppConfigService { + t.Helper() + + var svc *AppConfigService + testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) { + var err error + svc, err = NewService(t.Context(), h, db) + require.NoError(t, err) + }) + require.NotNil(t, svc) + + // The singleton actor is bootstrapped asynchronously once the host is ready. + // Before bootstrap runs, the actor has no state and GetConfig decodes it into a non-nil but zero config, so wait until a non-zero (bootstrapped) config is available before returning. + require.Eventually(t, func() bool { + cfg, err := svc.GetConfig(t.Context()) + return err == nil && cfg != nil && *cfg != (AppConfigModel{}) + }, 10*time.Second, 20*time.Millisecond, "config actor was not bootstrapped in time") + + return svc +} + +// seedLegacyConfig writes a legacy config blob to the kv table so the AppConfig actor bootstraps from it. +func seedLegacyConfig(t *testing.T, db *gorm.DB, values map[string]string) { + t.Helper() + + blob, err := json.Marshal(values) + require.NoError(t, err) + + value := string(blob) + err = db.Create(&model.KV{Key: "config_migrated", Value: &value}).Error + require.NoError(t, err) +} + +// findConfigValue returns the value for key in a slice of AppConfigVariable, and whether it was found. +func findConfigValue(vars []AppConfigVariable, key string) (string, bool) { + for _, v := range vars { + if v.Key == key { + return v.Value, true + } + } + return "", false +} + +func TestService_NewService(t *testing.T) { + t.Run("bootstraps the default config when the database is empty", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + cfg, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, *getDefaultConfig(), *cfg) + }) + + t.Run("bootstraps from the legacy config in the database", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + seedLegacyConfig(t, db, map[string]string{ + "appName": "Legacy App", + "ldapEnabled": "true", + }) + + svc := newActorBackedService(t, db) + + cfg, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, AppConfigValue("Legacy App"), cfg.AppName) + assert.Equal(t, AppConfigValue("true"), cfg.LdapEnabled) + // Keys not present in the legacy config keep their defaults + assert.Equal(t, getDefaultConfig().SessionDuration, cfg.SessionDuration) + }) + + t.Run("loads config from the environment when the UI config is disabled", func(t *testing.T) { + setUIConfigDisabled(t, true) + t.Setenv("APP_NAME", "Environment App") + + // No actor host or database is needed when the UI config is disabled + svc, err := NewService(t.Context(), nil, nil) + require.NoError(t, err) + require.NotNil(t, svc) + + cfg, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, AppConfigValue("Environment App"), cfg.AppName) + }) +} + +func TestService_GetConfig(t *testing.T) { + t.Run("returns a fresh copy on each call", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + first, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + + // Mutating the returned config must not affect what the service returns later + first.AppName = "Mutated" + + second, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, getDefaultConfig().AppName, second.AppName) + }) + + t.Run("returns the env config when the UI config is disabled", func(t *testing.T) { + setUIConfigDisabled(t, true) + svc := NewTestAppConfigService(&AppConfigModel{AppName: "From Env"}) + + cfg, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, AppConfigValue("From Env"), cfg.AppName) + }) +} + +func TestService_UpdateAppConfig(t *testing.T) { + t.Run("replaces the configuration and returns all variables", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + input := dto.AppConfigUpdateDto{ + AppName: "Replaced App", + SessionDuration: "120", + LdapEnabled: "true", + SmtpTls: "tls", + } + res, err := svc.UpdateAppConfig(t.Context(), input) + require.NoError(t, err) + + // The returned slice includes all variables, both public and private + got, ok := findConfigValue(res, "appName") + require.True(t, ok) + assert.Equal(t, "Replaced App", got) + got, ok = findConfigValue(res, "smtpTls") + require.True(t, ok, "the returned slice should include private variables") + assert.Equal(t, "tls", got) + + // The change is persisted and visible on subsequent reads + cfg, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, AppConfigValue("Replaced App"), cfg.AppName) + assert.Equal(t, AppConfigValue("120"), cfg.SessionDuration) + assert.Equal(t, AppConfigValue("true"), cfg.LdapEnabled) + }) + + t.Run("resets fields omitted from the DTO to their defaults", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + // First set some non-default values + _, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{ + AppName: "First", + LdapEnabled: "true", + SmtpTls: "tls", + }) + require.NoError(t, err) + + // Replace again with only AppName set: the rest must reset to their defaults + _, err = svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{AppName: "Second"}) + require.NoError(t, err) + + cfg, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, AppConfigValue("Second"), cfg.AppName) + assert.Equal(t, getDefaultConfig().LdapEnabled, cfg.LdapEnabled) + assert.Equal(t, getDefaultConfig().SmtpTls, cfg.SmtpTls) + }) + + t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) { + setUIConfigDisabled(t, true) + svc := NewTestAppConfigService(nil) + + _, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{AppName: "X"}) + require.Error(t, err) + var target *common.UiConfigDisabledError + assert.ErrorAs(t, err, &target) + }) +} + +func TestService_UpdateAppConfigValues(t *testing.T) { + t.Run("updates a subset of keys and leaves the rest unchanged", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + err := svc.UpdateAppConfigValues(t.Context(), "appName", "Updated", "sessionDuration", "120") + require.NoError(t, err) + + cfg, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, AppConfigValue("Updated"), cfg.AppName) + assert.Equal(t, AppConfigValue("120"), cfg.SessionDuration) + // A key that was not part of the update keeps its default + assert.Equal(t, getDefaultConfig().LdapEnabled, cfg.LdapEnabled) + }) + + t.Run("an empty value resets the property to its default", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + // Set a non-default value first + err := svc.UpdateAppConfigValues(t.Context(), "sessionDuration", "120") + require.NoError(t, err) + + // Then reset it with an empty value + err = svc.UpdateAppConfigValues(t.Context(), "sessionDuration", "") + require.NoError(t, err) + + cfg, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, getDefaultConfig().SessionDuration, cfg.SessionDuration) + }) + + t.Run("an odd number of arguments returns an error", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + err := svc.UpdateAppConfigValues(t.Context(), "appName") + require.Error(t, err) + assert.ErrorContains(t, err, "invalid number of arguments received") + }) + + t.Run("an unknown key returns an error and does not change the config", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + err := svc.UpdateAppConfigValues(t.Context(), "thisKeyDoesNotExist", "value") + require.Error(t, err) + + // The config must not have been modified + cfg, err := svc.GetConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, *getDefaultConfig(), *cfg) + }) + + t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) { + setUIConfigDisabled(t, true) + svc := NewTestAppConfigService(nil) + + // An even number of arguments so the count check passes and we reach the UI-config check + err := svc.UpdateAppConfigValues(t.Context(), "appName", "X") + require.Error(t, err) + var target *common.UiConfigDisabledError + assert.ErrorAs(t, err, &target) + }) +} + +func TestService_ListAppConfig(t *testing.T) { + t.Run("returns only public variables when showAll is false", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + vars, err := svc.ListAppConfig(t.Context(), false) + require.NoError(t, err) + + // appName is public and must be present + _, ok := findConfigValue(vars, "appName") + assert.True(t, ok, "public variable appName should be present") + // smtpHost is not public and must be excluded + _, ok = findConfigValue(vars, "smtpHost") + assert.False(t, ok, "private variable smtpHost should be excluded") + }) + + t.Run("returns all variables when showAll is true", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + vars, err := svc.ListAppConfig(t.Context(), true) + require.NoError(t, err) + + _, ok := findConfigValue(vars, "appName") + assert.True(t, ok) + _, ok = findConfigValue(vars, "smtpHost") + assert.True(t, ok, "private variables should be included when showAll is true") + }) + + t.Run("reflects updates made through the service", func(t *testing.T) { + setUIConfigDisabled(t, false) + db := testutils.NewDatabaseForTest(t) + svc := newActorBackedService(t, db) + + err := svc.UpdateAppConfigValues(t.Context(), "appName", "Listed App") + require.NoError(t, err) + + vars, err := svc.ListAppConfig(t.Context(), true) + require.NoError(t, err) + + got, ok := findConfigValue(vars, "appName") + require.True(t, ok) + assert.Equal(t, "Listed App", got) + }) + + t.Run("redacts sensitive values when the UI config is disabled", func(t *testing.T) { + setUIConfigDisabled(t, true) + svc := NewTestAppConfigService(&AppConfigModel{ + SmtpPassword: "super-secret", + }) + + vars, err := svc.ListAppConfig(t.Context(), true) + require.NoError(t, err) + + got, ok := findConfigValue(vars, "smtpPassword") + require.True(t, ok) + assert.Equal(t, "XXXXXXXXXX", got) + }) +} diff --git a/backend/internal/appconfig/testing_unit.go b/backend/internal/appconfig/testing_unit.go new file mode 100644 index 00000000..3df1fa6c --- /dev/null +++ b/backend/internal/appconfig/testing_unit.go @@ -0,0 +1,27 @@ +//go:build unit + +// This file contains utils for unit tests and it's only built when the "unit" tag is set +package appconfig + +// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values +func NewTestAppConfigService(config *AppConfigModel) *AppConfigService { + if config == nil { + // If there's no config, set the default one + config = getDefaultConfig() + } + + service := &AppConfigService{ + envConfig: config, + } + + return service +} + +// NewTestConfig returns an application configuration for use in tests, falling back to the default configuration when none is provided +func NewTestConfig(config *AppConfigModel) *AppConfigModel { + if config == nil { + config = getDefaultConfig() + } + + return config +} diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 53505426..8fd171e8 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -100,7 +100,7 @@ func Bootstrap(ctx context.Context) error { services = append(services, actorsRun) // Create all services - svc, err := initServices(ctx, db, instanceID, httpClient, imageExtensions, fileStorage, scheduler) + svc, err := initServices(ctx, db, instanceID, actors, httpClient, imageExtensions, fileStorage, scheduler) if err != nil { return fmt.Errorf("failed to initialize services: %w", err) } diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 9969045f..11a71b96 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -158,11 +158,11 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate), ) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) - controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService) + controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.appConfigService, svc.userService, svc.oneTimeAccessService, svc.webauthnModule) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) - controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService) + controller.NewUserGroupController(apiGroup, authMiddleware, svc.appConfigService, svc.userGroupService) svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add()) controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) controller.NewVersionController(apiGroup, authMiddleware, svc.versionService) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index d05edf06..c3600f4f 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -5,12 +5,14 @@ import ( "fmt" "net/http" - "github.com/pocket-id/pocket-id/backend/internal/apikey" - "github.com/pocket-id/pocket-id/backend/internal/job" + "github.com/italypaleale/francis/host/local" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/api" + "github.com/pocket-id/pocket-id/backend/internal/apikey" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/job" "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/storage" @@ -19,7 +21,7 @@ import ( ) type services struct { - appConfigService *service.AppConfigService + appConfigService *appconfig.AppConfigService appImagesService *service.AppImagesService emailService *service.EmailService geoLiteService *service.GeoLiteService @@ -44,10 +46,20 @@ type services struct { } // Initializes all services -func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, scheduler *job.Scheduler) (svc *services, err error) { +func initServices( + ctx context.Context, + db *gorm.DB, + instanceID string, + actors *local.Host, + httpClient *http.Client, + imageExtensions map[string]string, + fileStorage storage.FileStorage, + scheduler *job.Scheduler, +) (svc *services, err error) { svc = &services{} - svc.appConfigService, err = service.NewAppConfigService(ctx, db) + // Init the app config service + svc.appConfigService, err = appconfig.NewService(ctx, actors, db) if err != nil { return nil, fmt.Errorf("failed to create app config service: %w", err) } @@ -56,14 +68,14 @@ func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClien svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage) svc.appLockService = service.NewAppLockService(db) - svc.emailService, err = service.NewEmailService(db, svc.appConfigService) + svc.emailService, err = service.NewEmailService(db) if err != nil { return nil, fmt.Errorf("failed to create email service: %w", err) } svc.geoLiteService = service.NewGeoLiteService(httpClient) - svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService) - svc.jwtService, err = service.NewJwtService(ctx, db, instanceID, svc.appConfigService) + svc.auditLogService = service.NewAuditLogService(db, svc.emailService, svc.geoLiteService, svc.appConfigService) + svc.jwtService, err = service.NewJwtService(ctx, db, instanceID) if err != nil { return nil, fmt.Errorf("failed to create JWT service: %w", err) } @@ -103,14 +115,14 @@ func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClien return nil, fmt.Errorf("failed to create OIDC module: %w", err) } - svc.oidcService, err = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.oidcModule.Preview, svc.scimService, httpClient, fileStorage) + svc.oidcService, err = service.NewOidcService(db, svc.jwtService, svc.oidcModule.Preview, svc.scimService, httpClient, fileStorage) if err != nil { return nil, fmt.Errorf("failed to create OIDC service: %w", err) } - svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService) - svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage) - svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage) + svc.userGroupService = service.NewUserGroupService(db, svc.scimService) + svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage) + svc.ldapService = service.NewLdapService(db, httpClient, svc.userService, svc.userGroupService, fileStorage) svc.apiKeyModule, err = apikey.New(ctx, apikey.Dependencies{ DB: db, @@ -124,10 +136,10 @@ func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClien DB: db, Signer: svc.jwtService, AuditLog: svc.auditLogService, - AppConfig: svc.appConfigService, UserCreator: svc.userService, + AppConfig: svc.appConfigService, }) - svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService) + svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService) svc.versionService = service.NewVersionService(httpClient) diff --git a/backend/internal/cmds/encryption_key_rotate.go b/backend/internal/cmds/encryption_key_rotate.go index d28a535f..e620ac3c 100644 --- a/backend/internal/cmds/encryption_key_rotate.go +++ b/backend/internal/cmds/encryption_key_rotate.go @@ -6,13 +6,13 @@ import ( "fmt" "os" - "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/spf13/cobra" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/bootstrap" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/instanceid" + "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" diff --git a/backend/internal/cmds/encryption_key_rotate_test.go b/backend/internal/cmds/encryption_key_rotate_test.go index a90749ee..a22f0e46 100644 --- a/backend/internal/cmds/encryption_key_rotate_test.go +++ b/backend/internal/cmds/encryption_key_rotate_test.go @@ -4,12 +4,12 @@ import ( "testing" "time" - "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/instanceid" + "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" diff --git a/backend/internal/common/internal_errors.go b/backend/internal/common/internal_errors.go new file mode 100644 index 00000000..26765d3e --- /dev/null +++ b/backend/internal/common/internal_errors.go @@ -0,0 +1,20 @@ +package common + +import ( + "errors" +) + +// ErrUnsupportedActorMethod is returned by custom actors when the invoked method isn't supported +type ErrUnsupportedActorMethod struct { + Method string +} + +func (e ErrUnsupportedActorMethod) Error() string { + return "method '" + e.Method + "' unsupported for actor invocation" +} + +func (e ErrUnsupportedActorMethod) Is(target error) bool { + // Ignore the field method when checking if an error is of the type ErrUnsupportedActorMethod + _, ok := errors.AsType[ErrUnsupportedActorMethod](target) + return ok +} diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index c52d8b1c..289cd765 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/middleware" @@ -19,7 +20,7 @@ import ( func NewAppConfigController( group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, - appConfigService *service.AppConfigService, + appConfigService *appconfig.AppConfigService, emailService *service.EmailService, ldapService *service.LdapService, ) { @@ -38,7 +39,7 @@ func NewAppConfigController( } type AppConfigController struct { - appConfigService *service.AppConfigService + appConfigService *appconfig.AppConfigService emailService *service.EmailService ldapService *service.LdapService } @@ -52,7 +53,12 @@ type AppConfigController struct { // @Success 200 {array} dto.PublicAppConfigVariableDto // @Router /api/application-configuration [get] func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { - configuration := acc.appConfigService.ListAppConfig(false) + dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(err) + return + } + configuration := dbConfig.ToAppConfigVariableSlice(false, true) var configVariablesDto []dto.PublicAppConfigVariableDto if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { @@ -86,7 +92,12 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { // @Success 200 {array} dto.AppConfigVariableDto // @Router /api/application-configuration/all [get] func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) { - configuration := acc.appConfigService.ListAppConfig(true) + dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(err) + return + } + configuration := dbConfig.ToAppConfigVariableSlice(true, true) var configVariablesDto []dto.AppConfigVariableDto if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { @@ -135,7 +146,13 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { // @Success 204 "No Content" // @Router /api/application-configuration/sync-ldap [post] func (acc *AppConfigController) syncLdapHandler(c *gin.Context) { - err := acc.ldapService.SyncAll(c.Request.Context()) + dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(err) + return + } + + err = acc.ldapService.SyncAll(c.Request.Context(), dbConfig) if err != nil { _ = c.Error(err) return @@ -151,9 +168,15 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) { // @Success 204 "No Content" // @Router /api/application-configuration/test-email [post] func (acc *AppConfigController) testEmailHandler(c *gin.Context) { + dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(err) + return + } + userID := c.GetString("userID") - err := acc.emailService.SendTestEmail(c.Request.Context(), userID) + err = acc.emailService.SendTestEmail(c.Request.Context(), dbConfig, userID) if err != nil { _ = c.Error(err) return diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 615aa21e..57ea9095 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -1,9 +1,11 @@ package controller import ( + "fmt" "net/http" "time" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" @@ -21,12 +23,12 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute // @Summary User management controller // @Description Initializes all user-related API endpoints // @Tags Users -func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) { +func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, appConfigService *appconfig.AppConfigService, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module) { uc := UserController{ + appConfigService: appConfigService, userService: userService, oneTimeAccessService: oneTimeAccessService, webAuthnService: webAuthnService, - appConfigService: appConfigService, } group.GET("/users", authMiddleware.Add(), uc.listUsersHandler) @@ -61,10 +63,10 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi } type UserController struct { + appConfigService *appconfig.AppConfigService userService *service.UserService oneTimeAccessService *service.OneTimeAccessService webAuthnService *webauthn.Module - appConfigService *service.AppConfigService } // getUserGroupsHandler godoc @@ -207,7 +209,13 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) { // @Success 204 "No Content" // @Router /api/users/{id} [delete] func (uc *UserController) deleteUserHandler(c *gin.Context) { - if err := uc.userService.DeleteUser(c.Request.Context(), c.Param("id"), false); err != nil { + dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + + if err := uc.userService.DeleteUser(c.Request.Context(), dbConfig, c.Param("id"), false); err != nil { _ = c.Error(err) return } @@ -248,13 +256,19 @@ func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) { // @Success 201 {object} dto.UserDto // @Router /api/users [post] func (uc *UserController) createUserHandler(c *gin.Context) { + dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + var input dto.UserCreateDto if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } - user, err := uc.userService.CreateUser(c.Request.Context(), input) + user, err := uc.userService.CreateUser(c.Request.Context(), dbConfig, input) if err != nil { _ = c.Error(err) return @@ -452,13 +466,19 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) { // @Success 204 "No Content" // @Router /api/one-time-access-email [post] func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) { + dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } - deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath) + deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), dbConfig, input.Email, input.RedirectPath) if err != nil { _ = c.Error(err) return @@ -479,6 +499,12 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler( // @Success 204 "No Content" // @Router /api/users/{id}/one-time-access-email [post] func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context) { + dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + var input dto.OneTimeAccessEmailAsAdminDto if err := c.ShouldBindJSON(&input); err != nil { _ = c.Error(err) @@ -491,7 +517,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context if ttl <= 0 { ttl = defaultOneTimeAccessTokenDuration } - err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl) + err = uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), dbConfig, userID, ttl) if err != nil { _ = c.Error(err) return @@ -508,6 +534,12 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context // @Success 200 {object} dto.UserDto // @Router /api/one-time-access-token/{token} [post] func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { + cfg, err := uc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + loginCode := c.Param("token") // reject invalid length login codes if len(loginCode) != 6 && len(loginCode) != 16 { @@ -516,19 +548,20 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { } deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName) - user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent()) + user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), cfg, loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent()) if err != nil { _ = c.Error(err) return } var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { + err = dto.MapStruct(user, &userDto) + if err != nil { _ = c.Error(err) return } - maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) @@ -566,6 +599,12 @@ func (uc *UserController) updateUserGroups(c *gin.Context) { // updateUser is an internal helper method, not exposed as an API endpoint func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { + dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + var input dto.UserCreateDto if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) @@ -579,7 +618,7 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { userID = c.Param("id") } - user, err := uc.userService.UpdateUser(c.Request.Context(), userID, input, updateOwnUser, false) + user, err := uc.userService.UpdateUser(c.Request.Context(), dbConfig, userID, input, updateOwnUser, false) if err != nil { _ = c.Error(err) return @@ -639,9 +678,15 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) // @Success 204 "No Content" // @Router /api/users/me/send-email-verification [post] func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) { + dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + userID := c.GetString("userID") - if err := uc.userService.SendEmailVerification(c.Request.Context(), userID); err != nil { + if err := uc.userService.SendEmailVerification(c.Request.Context(), dbConfig, userID); err != nil { _ = c.Error(err) return } diff --git a/backend/internal/controller/user_group_controller.go b/backend/internal/controller/user_group_controller.go index a0567752..5ecfd319 100644 --- a/backend/internal/controller/user_group_controller.go +++ b/backend/internal/controller/user_group_controller.go @@ -1,9 +1,11 @@ package controller import ( + "fmt" "net/http" "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" @@ -14,8 +16,9 @@ import ( // @Summary User group management controller // @Description Initializes all user group-related API endpoints // @Tags User Groups -func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) { +func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, appConfigService *appconfig.AppConfigService, userGroupService *service.UserGroupService) { ugc := UserGroupController{ + appConfigService: appConfigService, UserGroupService: userGroupService, } @@ -33,6 +36,7 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A } type UserGroupController struct { + appConfigService *appconfig.AppConfigService UserGroupService *service.UserGroupService } @@ -146,13 +150,19 @@ func (ugc *UserGroupController) create(c *gin.Context) { // @Success 200 {object} dto.UserGroupDto "Updated user group" // @Router /api/user-groups/{id} [put] func (ugc *UserGroupController) update(c *gin.Context) { + dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + var input dto.UserGroupCreateDto if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } - group, err := ugc.UserGroupService.Update(c.Request.Context(), c.Param("id"), input) + group, err := ugc.UserGroupService.Update(c.Request.Context(), dbConfig, c.Param("id"), input) if err != nil { _ = c.Error(err) return @@ -177,7 +187,13 @@ func (ugc *UserGroupController) update(c *gin.Context) { // @Success 204 "No Content" // @Router /api/user-groups/{id} [delete] func (ugc *UserGroupController) delete(c *gin.Context) { - if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil { + dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + + if err := ugc.UserGroupService.Delete(c.Request.Context(), dbConfig, c.Param("id")); err != nil { _ = c.Error(err) return } diff --git a/backend/internal/instanceid/instanceid_test.go b/backend/internal/instanceid/instanceid_test.go index b850efd2..c335a0d7 100644 --- a/backend/internal/instanceid/instanceid_test.go +++ b/backend/internal/instanceid/instanceid_test.go @@ -137,15 +137,6 @@ func TestMigrateFromAppConfig(t *testing.T) { } } - // countLegacyInstanceID returns the number of "instanceId" rows left in the app_config_variables table - countLegacyInstanceID := func(t *testing.T, db *gorm.DB) int64 { - t.Helper() - var count int64 - err := db.Table("app_config_variables").Where(`"key" = ?`, "instanceId").Count(&count).Error - require.NoError(t, err) - return count - } - t.Run("moves an existing instance ID from app_config_variables into the kv table", func(t *testing.T) { legacyID := uuid.NewString() db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMove, seedAppConfigInstanceID(legacyID)) @@ -155,8 +146,8 @@ func TestMigrateFromAppConfig(t *testing.T) { require.Equal(t, 1, count) require.Equal(t, legacyID, stored) - // The legacy row must have been removed from app_config_variables - require.Zero(t, countLegacyInstanceID(t, db)) + // The final config migration removes the legacy table after freezing its remaining values + require.False(t, db.Migrator().HasTable("app_config_variables")) // Load must return the migrated value without generating a new one id, err := Load(t.Context(), db) @@ -184,7 +175,8 @@ func TestMigrateFromAppConfig(t *testing.T) { require.Equal(t, 1, count) require.Equal(t, "kv-instance-id", stored) - // The legacy row must still be removed regardless of the conflict - require.Zero(t, countLegacyInstanceID(t, db)) + // The final config migration removes the legacy table regardless of the conflict + ok := db.Migrator().HasTable("app_config_variables") + require.False(t, ok) }) } diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go index eccdeb98..4717f0a2 100644 --- a/backend/internal/job/api_key_expiry_job.go +++ b/backend/internal/job/api_key_expiry_job.go @@ -8,17 +8,18 @@ import ( "github.com/go-co-op/gocron/v2" "github.com/pocket-id/pocket-id/backend/internal/apikey" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils/email" ) type ApiKeyEmailJobs struct { apiKeyModule *apikey.Module - appConfigService *service.AppConfigService + appConfigService *appconfig.AppConfigService emailService *service.EmailService } -func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *service.AppConfigService, emailService *service.EmailService) error { +func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *appconfig.AppConfigService, emailService *service.EmailService) error { jobs := &ApiKeyEmailJobs{ apiKeyModule: apiKeyModule, appConfigService: appConfigService, @@ -30,8 +31,13 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *a } func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error { + dbConfig, err := j.appConfigService.GetConfig(ctx) + if err != nil { + return fmt.Errorf("error load app config: %w", err) + } + // Skip if the feature is disabled - if !j.appConfigService.GetDbConfig().EmailApiKeyExpirationEnabled.IsTrue() { + if !dbConfig.EmailApiKeyExpirationEnabled.IsTrue() { return nil } @@ -45,7 +51,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err continue } - err = service.SendEmail(ctx, j.emailService, email.Address{ + err = service.SendEmail(ctx, j.emailService, dbConfig, email.Address{ Name: key.User.FullName(), Email: *key.User.Email, }, service.ApiKeyExpiringSoonTemplate, &service.ApiKeyExpiringSoonTemplateData{ diff --git a/backend/internal/job/ldap_job.go b/backend/internal/job/ldap_job.go index 1547d954..4c450bcb 100644 --- a/backend/internal/job/ldap_job.go +++ b/backend/internal/job/ldap_job.go @@ -2,17 +2,19 @@ package job import ( "context" + "fmt" "time" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/service" ) type LdapJobs struct { ldapService *service.LdapService - appConfigService *service.AppConfigService + appConfigService *appconfig.AppConfigService } -func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error { +func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *appconfig.AppConfigService) error { jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService} // Register the job to run every hour (with some jitter) @@ -20,9 +22,14 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L } func (j *LdapJobs) syncLdap(ctx context.Context) error { - if !j.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { + dbConfig, err := j.appConfigService.GetConfig(ctx) + if err != nil { + return fmt.Errorf("error load app config: %w", err) + } + + if !dbConfig.LdapEnabled.IsTrue() { return nil } - return j.ldapService.SyncAll(ctx) + return j.ldapService.SyncAll(ctx, dbConfig) } diff --git a/backend/internal/middleware/auth_middleware_test.go b/backend/internal/middleware/auth_middleware_test.go index 08379af2..22c98b43 100644 --- a/backend/internal/middleware/auth_middleware_test.go +++ b/backend/internal/middleware/auth_middleware_test.go @@ -33,23 +33,20 @@ func TestWithApiKeyAuthDisabled(t *testing.T) { db := testutils.NewDatabaseForTest(t) - appConfigService, err := service.NewAppConfigService(t.Context(), db) - require.NoError(t, err) - instanceID, err := instanceid.Load(t.Context(), db) require.NoError(t, err) - jwtService, err := service.NewJwtService(t.Context(), db, instanceID, appConfigService) + jwtService, err := service.NewJwtService(t.Context(), db, instanceID) require.NoError(t, err) - userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil) + userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil, nil) apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db}) require.NoError(t, err) authMiddleware := NewAuthMiddleware(apiKeyModule, userService, jwtService) user := createUserForAuthMiddlewareTest(t, db) - jwtToken, err := jwtService.GenerateAccessToken(user, "") + jwtToken, err := jwtService.GenerateAccessToken(user, "", time.Hour) require.NoError(t, err) apiKeyToken := "middleware-test-api-key-raw-token" diff --git a/backend/internal/middleware/rate_limit_test.go b/backend/internal/middleware/rate_limit_test.go index 863b6204..42a567bc 100644 --- a/backend/internal/middleware/rate_limit_test.go +++ b/backend/internal/middleware/rate_limit_test.go @@ -39,6 +39,7 @@ func startRateLimitServices(t *testing.T, policies ...RateLimitPolicy) map[strin for name, rl := range limiters { services[name] = rl.Service(svc) } + return services } diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go deleted file mode 100644 index 76d508d5..00000000 --- a/backend/internal/model/app_config.go +++ /dev/null @@ -1,203 +0,0 @@ -package model - -import ( - "errors" - "fmt" - "reflect" - "slices" - "strconv" - "strings" - "time" - - "github.com/pocket-id/pocket-id/backend/internal/common" -) - -type AppConfigVariable struct { - Key string `gorm:"primaryKey;not null"` - Value string -} - -// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc. -func (a *AppConfigVariable) IsTrue() bool { - ok, _ := strconv.ParseBool(a.Value) - return ok -} - -// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes. -func (a *AppConfigVariable) AsDurationMinutes() time.Duration { - val, err := strconv.Atoi(a.Value) - if err != nil { - return 0 - } - return time.Duration(val) * time.Minute -} - -type AppConfig struct { - // General - AppName AppConfigVariable `key:"appName,public"` // Public - SessionDuration AppConfigVariable `key:"sessionDuration"` - HomePageURL AppConfigVariable `key:"homePageUrl,public"` // Public - EmailsVerified AppConfigVariable `key:"emailsVerified"` - AccentColor AppConfigVariable `key:"accentColor,public"` // Public - DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public - AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public - AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public - SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"` - SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"` - // Email - RequireUserEmail AppConfigVariable `key:"requireUserEmail,public"` // Public - SmtpHost AppConfigVariable `key:"smtpHost"` - SmtpPort AppConfigVariable `key:"smtpPort"` - SmtpFrom AppConfigVariable `key:"smtpFrom"` - SmtpUser AppConfigVariable `key:"smtpUser"` - SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"` - SmtpTls AppConfigVariable `key:"smtpTls"` - SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"` - EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"` - EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public - EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public - EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"` - EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public - // LDAP - LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public - LdapUrl AppConfigVariable `key:"ldapUrl"` - LdapBindDn AppConfigVariable `key:"ldapBindDn"` - LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"` - LdapBase AppConfigVariable `key:"ldapBase"` - LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"` - LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"` - LdapSkipCertVerify AppConfigVariable `key:"ldapSkipCertVerify"` - LdapAttributeUserUniqueIdentifier AppConfigVariable `key:"ldapAttributeUserUniqueIdentifier"` - LdapAttributeUserUsername AppConfigVariable `key:"ldapAttributeUserUsername"` - LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"` - LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"` - LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"` - LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"` - LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"` - LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"` - LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"` - LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"` - LdapAdminGroupName AppConfigVariable `key:"ldapAdminGroupName"` - LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"` -} - -func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable { - // Use reflection to iterate through all fields - cfgValue := reflect.ValueOf(c).Elem() - cfgType := cfgValue.Type() - - var res []AppConfigVariable - - for i := range cfgType.NumField() { - field := cfgType.Field(i) - - key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",") - if key == "" { - continue - } - - // If we're only showing public variables and this is not public, skip it - if !showAll && attrs != "public" { - continue - } - - value := cfgValue.Field(i).FieldByName("Value").String() - - // Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true - if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && attrs == "sensitive" { - value = "XXXXXXXXXX" - } - - appConfigVariable := AppConfigVariable{ - Key: key, - Value: value, - } - - res = append(res, appConfigVariable) - } - - return res -} - -func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool, err error) { - rv := reflect.ValueOf(c).Elem() - rt := rv.Type() - - // Find the field in the struct whose "key" tag matches - for i := range rt.NumField() { - // Grab only the first part of the key, if there's a comma with additional properties - tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",") - keyFromTag := tagValue[0] - isInternal = slices.Contains(tagValue, "internal") - if keyFromTag != key { - continue - } - - valueField := rv.Field(i).FieldByName("Value") - return valueField.String(), isInternal, nil - } - - // If we are here, the config key was not found - return "", false, AppConfigKeyNotFoundError{field: key} -} - -func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error { - rv := reflect.ValueOf(c).Elem() - rt := rv.Type() - - // Find the field in the struct whose "key" tag matches, then update that - for i := range rt.NumField() { - // Separate the key (before the comma) from any optional attributes after - tagValue, attrs, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",") - if tagValue != key { - continue - } - - // If the field is internal and noInternal is true, we skip that - if noInternal && attrs == "internal" { - return AppConfigInternalForbiddenError{field: key} - } - - valueField := rv.Field(i).FieldByName("Value") - if !valueField.CanSet() { - return fmt.Errorf("field Value in AppConfigVariable is not settable for config key '%s'", key) - } - - // Update the value - valueField.SetString(value) - - // Return once updated - return nil - } - - // If we're here, we have not found the right field to update - return AppConfigKeyNotFoundError{field: key} -} - -type AppConfigKeyNotFoundError struct { - field string -} - -func (e AppConfigKeyNotFoundError) Error() string { - return "cannot find config key '" + e.field + "'" -} - -func (e AppConfigKeyNotFoundError) Is(target error) bool { - // Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError - x := AppConfigKeyNotFoundError{} - return errors.As(target, &x) -} - -type AppConfigInternalForbiddenError struct { - field string -} - -func (e AppConfigInternalForbiddenError) Error() string { - return "field '" + e.field + "' is internal and can't be updated" -} - -func (e AppConfigInternalForbiddenError) Is(target error) bool { - // Ignore the field property when checking if an error is of the type AppConfigInternalForbiddenError - x := AppConfigInternalForbiddenError{} - return errors.As(target, &x) -} diff --git a/backend/internal/model/app_config_test.go b/backend/internal/model/app_config_test.go deleted file mode 100644 index 4f28eb99..00000000 --- a/backend/internal/model/app_config_test.go +++ /dev/null @@ -1,126 +0,0 @@ -// We use model_test here to avoid an import cycle -package model_test - -import ( - "reflect" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/model" -) - -func TestAppConfigVariable_AsMinutesDuration(t *testing.T) { - tests := []struct { - name string - value string - expected time.Duration - expectedSeconds int - }{ - { - name: "valid positive integer", - value: "60", - expected: 60 * time.Minute, - expectedSeconds: 3600, - }, - { - name: "valid zero integer", - value: "0", - expected: 0, - expectedSeconds: 0, - }, - { - name: "negative integer", - value: "-30", - expected: -30 * time.Minute, - expectedSeconds: -1800, - }, - { - name: "invalid non-integer", - value: "not-a-number", - expected: 0, - expectedSeconds: 0, - }, - { - name: "empty string", - value: "", - expected: 0, - expectedSeconds: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - configVar := model.AppConfigVariable{ - Value: tt.value, - } - - result := configVar.AsDurationMinutes() - assert.Equal(t, tt.expected, result) - assert.Equal(t, tt.expectedSeconds, int(result.Seconds())) - }) - } -} - -// This test ensures that the model.AppConfig and dto.AppConfigUpdateDto structs match: -// - They should have the same properties, where the "json" tag of dto.AppConfigUpdateDto should match the "key" tag in model.AppConfig -// - dto.AppConfigDto should not include "internal" fields from model.AppConfig -// This test is primarily meant to catch discrepancies between the two structs as fields are added or removed over time -func TestAppConfigStructMatchesUpdateDto(t *testing.T) { - appConfigType := reflect.TypeFor[model.AppConfig]() - updateDtoType := reflect.TypeFor[dto.AppConfigUpdateDto]() - - // Process AppConfig fields - appConfigFields := make(map[string]string) - for field := range appConfigType.Fields() { - if field.Tag.Get("key") == "" { - // Skip internal fields - continue - } - - // Extract the key name from the tag (takes the part before any comma) - keyTag := field.Tag.Get("key") - keyName, _, _ := strings.Cut(keyTag, ",") - - appConfigFields[field.Name] = keyName - } - - // Process AppConfigUpdateDto fields - dtoFields := make(map[string]string) - for field := range updateDtoType.Fields() { - // Extract the json name from the tag (takes the part before any binding constraints) - jsonTag := field.Tag.Get("json") - jsonName, _, _ := strings.Cut(jsonTag, ",") - - dtoFields[jsonName] = field.Name - } - - // Verify every AppConfig field has a matching DTO field with the same name - for fieldName, keyName := range appConfigFields { - if strings.HasSuffix(fieldName, "ImageType") { - // Skip internal fields that shouldn't be in the DTO - continue - } - - // Check if there's a DTO field with a matching JSON tag - _, exists := dtoFields[keyName] - assert.True(t, exists, "Field %s with key '%s' in AppConfig has no matching field in AppConfigUpdateDto", fieldName, keyName) - } - - // Verify every DTO field has a matching AppConfig field - for jsonName, fieldName := range dtoFields { - // Find a matching field in AppConfig by key tag - found := false - for _, keyName := range appConfigFields { - if keyName == jsonName { - found = true - break - } - } - - assert.True(t, found, "Field %s with json tag '%s' in AppConfigUpdateDto has no matching field in AppConfig", fieldName, jsonName) - } -} diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go deleted file mode 100644 index d2670fa9..00000000 --- a/backend/internal/service/app_config_service.go +++ /dev/null @@ -1,416 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "os" - "reflect" - "strings" - "sync/atomic" - "time" - - "gorm.io/gorm" - "gorm.io/gorm/clause" - - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/model" - "github.com/pocket-id/pocket-id/backend/internal/tracing" - "github.com/pocket-id/pocket-id/backend/internal/utils" -) - -type AppConfigService struct { - dbConfig atomic.Pointer[model.AppConfig] - db *gorm.DB -} - -func NewAppConfigService(ctx context.Context, db *gorm.DB) (service *AppConfigService, err error) { - service = &AppConfigService{ - db: db, - } - - ctx, span := tracing.Start(ctx, "pocketid.appconfig.init") - defer tracing.End(span, err) - - // We need to assign to the "err" variable, do not inline this into the "if" - err = service.LoadDbConfig(ctx) - if err != nil { - return nil, fmt.Errorf("failed to initialize app config service: %w", err) - } - - return service, nil -} - -// GetDbConfig returns the application configuration. -// Important: Treat the object as read-only: do not modify its properties directly! -func (s *AppConfigService) GetDbConfig() *model.AppConfig { - v := s.dbConfig.Load() - if v == nil { - // This indicates a development-time error - panic("called GetDbConfig before DbConfig is loaded") - } - - return v -} - -func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { - // Values are the default ones - return &model.AppConfig{ - // General - AppName: model.AppConfigVariable{Value: "Pocket ID"}, - SessionDuration: model.AppConfigVariable{Value: "60"}, - HomePageURL: model.AppConfigVariable{Value: "/settings/account"}, - EmailsVerified: model.AppConfigVariable{Value: "false"}, - DisableAnimations: model.AppConfigVariable{Value: "false"}, - AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"}, - AllowUserSignups: model.AppConfigVariable{Value: "disabled"}, - SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"}, - SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"}, - AccentColor: model.AppConfigVariable{Value: "default"}, - // Email - RequireUserEmail: model.AppConfigVariable{Value: "true"}, - SmtpHost: model.AppConfigVariable{}, - SmtpPort: model.AppConfigVariable{}, - SmtpFrom: model.AppConfigVariable{}, - SmtpUser: model.AppConfigVariable{}, - SmtpPassword: model.AppConfigVariable{}, - SmtpTls: model.AppConfigVariable{Value: "none"}, - SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"}, - EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"}, - EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"}, - EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"}, - EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"}, - EmailVerificationEnabled: model.AppConfigVariable{Value: "false"}, - // LDAP - LdapEnabled: model.AppConfigVariable{Value: "false"}, - LdapUrl: model.AppConfigVariable{}, - LdapBindDn: model.AppConfigVariable{}, - LdapBindPassword: model.AppConfigVariable{}, - LdapBase: model.AppConfigVariable{}, - LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"}, - LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"}, - LdapSkipCertVerify: model.AppConfigVariable{Value: "false"}, - LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{}, - LdapAttributeUserUsername: model.AppConfigVariable{}, - LdapAttributeUserEmail: model.AppConfigVariable{}, - LdapAttributeUserFirstName: model.AppConfigVariable{}, - LdapAttributeUserLastName: model.AppConfigVariable{}, - LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"}, - LdapAttributeUserProfilePicture: model.AppConfigVariable{}, - LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"}, - LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{}, - LdapAttributeGroupName: model.AppConfigVariable{}, - LdapAdminGroupName: model.AppConfigVariable{}, - LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"}, - } -} - -func (s *AppConfigService) updateAppConfigStartTransaction(ctx context.Context) (tx *gorm.DB, err error) { - // We start a transaction before doing any work, to ensure that we are the only ones updating the data in the database - // This works across multiple processes too - tx = s.db.Begin() - err = tx.Error - if err != nil { - return nil, fmt.Errorf("failed to begin database transaction: %w", err) - } - - // With SQLite there's nothing else we need to do, because a transaction blocks the entire database - // However, with Postgres we need to manually lock the table to prevent others from doing the same - switch s.db.Name() { - case "postgres": - // We do not use "NOWAIT" so this blocks until the database is available, or the context is canceled - // Here we use a context with a 10s timeout in case the database is blocked for longer - lockCtx, lockCancel := context.WithTimeout(ctx, 10*time.Second) - defer lockCancel() - err = tx. - WithContext(lockCtx). - Exec("LOCK TABLE app_config_variables IN ACCESS EXCLUSIVE MODE"). - Error - if err != nil { - tx.Rollback() - return nil, fmt.Errorf("failed to acquire lock on app_config_variables table: %w", err) - } - default: - // Nothing to do here - } - - return tx, nil -} - -func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error { - err := tx. - WithContext(ctx). - Clauses(clause.OnConflict{ - // Perform an "upsert" if the key already exists, replacing the value - Columns: []clause.Column{{Name: "key"}}, - DoUpdates: clause.AssignmentColumns([]string{"value"}), - }). - Create(&dbUpdate). - Error - if err != nil { - return fmt.Errorf("failed to update config in database: %w", err) - } - - return nil -} - -func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { - if common.EnvConfig.UiConfigDisabled { - return nil, &common.UiConfigDisabledError{} - } - - // Start the transaction - tx, err := s.updateAppConfigStartTransaction(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback() - - // From here onwards, we know we are the only process/goroutine with exclusive access to the config - // Re-load the config from the database to be sure we have the correct data - cfg, err := s.loadDbConfigInternal(ctx, tx) - if err != nil { - return nil, fmt.Errorf("failed to reload config from database: %w", err) - } - - defaultCfg := s.getDefaultDbConfig() - - // Iterate through all the fields to update - // We update the in-memory data (in the cfg struct) and collect values to update in the database - rt := reflect.ValueOf(input).Type() - rv := reflect.ValueOf(input) - dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField()) - for field := range rt.Fields() { - value := rv.FieldByName(field.Name).String() - - // Get the value of the json tag, taking only what's before the comma - key, _, _ := strings.Cut(field.Tag.Get("json"), ",") - - // Update the in-memory config value - // If the new value is an empty string, then we set the in-memory value to the default one - // Skip values that are internal only and can't be updated - if value == "" { - // Ignore errors here as we know the key exists - defaultValue, _, _ := defaultCfg.FieldByKey(key) - err = cfg.UpdateField(key, defaultValue, true) - } else { - err = cfg.UpdateField(key, value, true) - } - - // If we tried to update an internal field, ignore the error (and do not update in the DB) - if errors.Is(err, model.AppConfigInternalForbiddenError{}) { - continue - } else if err != nil { - return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err) - } - - // We always save "value" which can be an empty string - dbUpdate = append(dbUpdate, model.AppConfigVariable{ - Key: key, - Value: value, - }) - } - - // Update the values in the database - err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate) - if err != nil { - return nil, err - } - - // Commit the changes to the DB, then finally save the updated config in the object - err = tx.Commit().Error - if err != nil { - return nil, fmt.Errorf("failed to commit transaction: %w", err) - } - - s.dbConfig.Store(cfg) - - // Return the updated config - res := cfg.ToAppConfigVariableSlice(true, false) - return res, nil -} - -// UpdateAppConfigValues updates the application configuration values in the database. -func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error { - // Count of keysAndValues must be even - if len(keysAndValues)%2 != 0 { - return errors.New("invalid number of arguments received") - } - - // Start the transaction - tx, err := s.updateAppConfigStartTransaction(ctx) - if err != nil { - return err - } - defer tx.Rollback() - - // From here onwards, we know we are the only process/goroutine with exclusive access to the config - // Re-load the config from the database to be sure we have the correct data - cfg, err := s.loadDbConfigInternal(ctx, tx) - if err != nil { - return fmt.Errorf("failed to reload config from database: %w", err) - } - - defaultCfg := s.getDefaultDbConfig() - - // Iterate through all the fields to update - // We update the in-memory data (in the cfg struct) and collect values to update in the database - // (Note the += 2, as we are iterating through key-value pairs) - dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2) - for i := 1; i < len(keysAndValues); i += 2 { - key := keysAndValues[i-1] - value := keysAndValues[i] - - // Ensure that the field is valid - // We do this by grabbing the default value - var defaultValue string - defaultValue, isInternal, err := defaultCfg.FieldByKey(key) - if err != nil { - return fmt.Errorf("invalid configuration key '%s': %w", key, err) - } - if !isInternal && common.EnvConfig.UiConfigDisabled { - return &common.UiConfigDisabledError{} - } - - // Update the in-memory config value - // If the new value is an empty string, then we set the in-memory value to the default one - // Skip values that are internal only and can't be updated - if value == "" { - err = cfg.UpdateField(key, defaultValue, false) - } else { - err = cfg.UpdateField(key, value, false) - } - if err != nil { - return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err) - } - - // We always save "value" which can be an empty string - dbUpdate = append(dbUpdate, model.AppConfigVariable{ - Key: key, - Value: value, - }) - } - - // Update the values in the database - err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate) - if err != nil { - return err - } - - // Commit the changes to the DB, then finally save the updated config in the object - err = tx.Commit().Error - if err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - s.dbConfig.Store(cfg) - - return nil -} - -func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable { - return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true) -} - -// LoadDbConfig loads the configuration values from the database into the DbConfig struct. -func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) { - dest, err := s.loadDbConfigInternal(ctx, s.db) - if err != nil { - return err - } - - s.dbConfig.Store(dest) - - return nil -} - -func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) { - // If the UI config is disabled, only load from the env - if common.EnvConfig.UiConfigDisabled { - dest, err := s.loadDbConfigFromEnv(ctx, tx) - return dest, err - } - - // First, start from the default configuration - dest := s.getDefaultDbConfig() - - // Load all configuration values from the database - // This loads all values in a single shot - var loaded []model.AppConfigVariable - queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second) - defer queryCancel() - err := tx. - WithContext(queryCtx). - Find(&loaded).Error - if err != nil { - return nil, fmt.Errorf("failed to load configuration from the database: %w", err) - } - - // Iterate through all values loaded from the database - for _, v := range loaded { - // Find the field in the struct whose "key" tag matches, then update that - err = dest.UpdateField(v.Key, v.Value, false) - - // We ignore the case of fields that don't exist, as there may be leftover data in the database - if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) { - return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err) - } - } - - return dest, nil -} - -func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) { - // First, start from the default configuration - dest := s.getDefaultDbConfig() - - // Iterate through each field - rt := reflect.ValueOf(dest).Elem().Type() - rv := reflect.ValueOf(dest).Elem() - for i := range rt.NumField() { - field := rt.Field(i) - - // Get the key and internal tag values - key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",") - - // Internal fields are loaded from the database as they can't be set from the environment - if attrs == "internal" { - var value string - err := tx.WithContext(ctx). - Model(&model.AppConfigVariable{}). - Where("key = ?", key). - Select("value"). - First(&value).Error - if err == nil { - rv.Field(i).FieldByName("Value").SetString(value) - } - continue - } - - envVarName := utils.CamelCaseToScreamingSnakeCase(key) - - // Set the value if it's set - value, ok := os.LookupEnv(envVarName) - if ok { - rv.Field(i).FieldByName("Value").SetString(value) - continue - } - - // If it's sensitive, we also allow reading from file - if attrs == "sensitive" { - fileName := os.Getenv(envVarName + "_FILE") - if fileName != "" { - // #nosec G703 - Value is provided by admin - b, err := os.ReadFile(fileName) - if err != nil { - return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err) - } - rv.Field(i).FieldByName("Value").SetString(string(b)) - continue - } - } - } - - return dest, nil -} diff --git a/backend/internal/service/app_config_service_test.go b/backend/internal/service/app_config_service_test.go deleted file mode 100644 index f22684fc..00000000 --- a/backend/internal/service/app_config_service_test.go +++ /dev/null @@ -1,473 +0,0 @@ -package service - -import ( - "sync/atomic" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/model" - testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" -) - -// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values -func NewTestAppConfigService(config *model.AppConfig) *AppConfigService { - service := &AppConfigService{ - dbConfig: atomic.Pointer[model.AppConfig]{}, - } - service.dbConfig.Store(config) - - return service -} - -func TestLoadDbConfig(t *testing.T) { - t.Run("empty config table", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - service := &AppConfigService{ - db: db, - } - - // Load the config - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Config should be equal to default config - require.Equal(t, service.GetDbConfig(), service.getDefaultDbConfig()) - }) - - t.Run("loads value from config table", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Populate the config table with some initial values - err := db. - Create([]model.AppConfigVariable{ - // Overrides default value - {Key: "appName", Value: "Test App"}, - {Key: "sessionDuration", Value: "5"}, - // Does not have a default value - {Key: "smtpHost", Value: "example"}, - }). - Error - require.NoError(t, err) - - // Load the config - service := &AppConfigService{ - db: db, - } - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Values should match expected ones - expect := service.getDefaultDbConfig() - expect.AppName.Value = "Test App" - expect.SessionDuration.Value = "5" - expect.SmtpHost.Value = "example" - require.Equal(t, service.GetDbConfig(), expect) - }) - - t.Run("ignores unknown config keys", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Add an entry with a key that doesn't exist in the config struct - err := db.Create([]model.AppConfigVariable{ - {Key: "__nonExistentKey", Value: "some value"}, - {Key: "appName", Value: "TestApp"}, // This one should still be loaded - }).Error - require.NoError(t, err) - - service := &AppConfigService{ - db: db, - } - // This should not fail, just ignore the unknown key - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - config := service.GetDbConfig() - require.Equal(t, "TestApp", config.AppName.Value) - }) - - t.Run("loading config multiple times", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Initial state - err := db.Create([]model.AppConfigVariable{ - {Key: "appName", Value: "InitialApp"}, - }).Error - require.NoError(t, err) - - service := &AppConfigService{ - db: db, - } - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - require.Equal(t, "InitialApp", service.GetDbConfig().AppName.Value) - - // Update the database value - err = db.Model(&model.AppConfigVariable{}). - Where("key = ?", "appName"). - Update("value", "UpdatedApp").Error - require.NoError(t, err) - - // Load the config again, it should reflect the updated value - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - require.Equal(t, "UpdatedApp", service.GetDbConfig().AppName.Value) - }) - - t.Run("loads config from env when UiConfigDisabled is true", func(t *testing.T) { - // Save the original state and restore it after the test - originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled - defer func() { - common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled - }() - - // Set environment variables for testing - t.Setenv("APP_NAME", "EnvTest App") - t.Setenv("SESSION_DURATION", "45") - - // Enable UiConfigDisabled to load from env - common.EnvConfig.UiConfigDisabled = true - - // Create database with config that should be ignored - db := testutils.NewDatabaseForTest(t) - err := db.Create([]model.AppConfigVariable{ - {Key: "appName", Value: "DB App"}, - {Key: "sessionDuration", Value: "120"}, - }).Error - require.NoError(t, err) - - service := &AppConfigService{ - db: db, - } - - // Load the config - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Config should be loaded from env, not DB - config := service.GetDbConfig() - require.Equal(t, "EnvTest App", config.AppName.Value, "Should load appName from env") - require.Equal(t, "45", config.SessionDuration.Value, "Should load sessionDuration from env") - }) - - t.Run("ignores env vars when UiConfigDisabled is false", func(t *testing.T) { - // Save the original state and restore it after the test - originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled - defer func() { - common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled - }() - - // Set environment variables that should be ignored - t.Setenv("APP_NAME", "EnvTest App") - t.Setenv("SESSION_DURATION", "45") - - // Make sure UiConfigDisabled is false to load from DB - common.EnvConfig.UiConfigDisabled = false - - // Create database with config values that should take precedence - db := testutils.NewDatabaseForTest(t) - err := db.Create([]model.AppConfigVariable{ - {Key: "appName", Value: "DB App"}, - {Key: "sessionDuration", Value: "120"}, - }).Error - require.NoError(t, err) - - service := &AppConfigService{ - db: db, - } - - // Load the config - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Config should be loaded from DB, not env - config := service.GetDbConfig() - require.Equal(t, "DB App", config.AppName.Value, "Should load appName from DB, not env") - require.Equal(t, "120", config.SessionDuration.Value, "Should load sessionDuration from DB, not env") - }) -} - -func TestUpdateAppConfigValues(t *testing.T) { - t.Run("update single value", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Update a single config value - err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App") - require.NoError(t, err) - - // Verify in-memory config was updated - config := service.GetDbConfig() - require.Equal(t, "Test App", config.AppName.Value) - - // Verify database was updated - var dbValue model.AppConfigVariable - err = db.Where("key = ?", "appName").First(&dbValue).Error - require.NoError(t, err) - require.Equal(t, "Test App", dbValue.Value) - }) - - t.Run("update multiple values", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Update multiple config values - err = service.UpdateAppConfigValues( - t.Context(), - "appName", "Test App", - "sessionDuration", "30", - "smtpHost", "mail.example.com", - ) - require.NoError(t, err) - - // Verify in-memory config was updated - config := service.GetDbConfig() - require.Equal(t, "Test App", config.AppName.Value) - require.Equal(t, "30", config.SessionDuration.Value) - require.Equal(t, "mail.example.com", config.SmtpHost.Value) - - // Verify database was updated - var count int64 - db.Model(&model.AppConfigVariable{}).Count(&count) - require.Equal(t, int64(3), count) - - var appName, sessionDuration, smtpHost model.AppConfigVariable - err = db.Where("key = ?", "appName").First(&appName).Error - require.NoError(t, err) - require.Equal(t, "Test App", appName.Value) - - err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error - require.NoError(t, err) - require.Equal(t, "30", sessionDuration.Value) - - err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error - require.NoError(t, err) - require.Equal(t, "mail.example.com", smtpHost.Value) - }) - - t.Run("empty value resets to default", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // First change the value - err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "30") - require.NoError(t, err) - require.Equal(t, "30", service.GetDbConfig().SessionDuration.Value) - - // Now set it to empty which should use default value - err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "") - require.NoError(t, err) - require.Equal(t, "60", service.GetDbConfig().SessionDuration.Value) // Default value from getDefaultDbConfig - }) - - t.Run("error with odd number of arguments", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Try to update with odd number of arguments - err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App", "sessionDuration") - require.Error(t, err) - require.Contains(t, err.Error(), "invalid number of arguments") - }) - - t.Run("error with invalid key", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Try to update with invalid key - err = service.UpdateAppConfigValues(t.Context(), "nonExistentKey", "some value") - require.Error(t, err) - require.Contains(t, err.Error(), "invalid configuration key") - }) -} - -func TestUpdateAppConfig(t *testing.T) { - t.Run("updates configuration values from DTO", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Create update DTO - input := dto.AppConfigUpdateDto{ - AppName: "Updated App Name", - SessionDuration: "120", - SmtpHost: "smtp.example.com", - SmtpPort: "587", - } - - // Update config - updatedVars, err := service.UpdateAppConfig(t.Context(), input) - require.NoError(t, err) - - // Verify returned updated variables - require.NotEmpty(t, updatedVars) - - var foundAppName, foundSessionDuration, foundSmtpHost, foundSmtpPort bool - for _, v := range updatedVars { - switch v.Key { - case "appName": - require.Equal(t, "Updated App Name", v.Value) - foundAppName = true - case "sessionDuration": - require.Equal(t, "120", v.Value) - foundSessionDuration = true - case "smtpHost": - require.Equal(t, "smtp.example.com", v.Value) - foundSmtpHost = true - case "smtpPort": - require.Equal(t, "587", v.Value) - foundSmtpPort = true - } - } - require.True(t, foundAppName) - require.True(t, foundSessionDuration) - require.True(t, foundSmtpHost) - require.True(t, foundSmtpPort) - - // Verify in-memory config was updated - config := service.GetDbConfig() - require.Equal(t, "Updated App Name", config.AppName.Value) - require.Equal(t, "120", config.SessionDuration.Value) - require.Equal(t, "smtp.example.com", config.SmtpHost.Value) - require.Equal(t, "587", config.SmtpPort.Value) - - // Verify database was updated - var appName, sessionDuration, smtpHost, smtpPort model.AppConfigVariable - err = db.Where("key = ?", "appName").First(&appName).Error - require.NoError(t, err) - require.Equal(t, "Updated App Name", appName.Value) - - err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error - require.NoError(t, err) - require.Equal(t, "120", sessionDuration.Value) - - err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error - require.NoError(t, err) - require.Equal(t, "smtp.example.com", smtpHost.Value) - - err = db.Where("key = ?", "smtpPort").First(&smtpPort).Error - require.NoError(t, err) - require.Equal(t, "587", smtpPort.Value) - }) - - t.Run("empty values reset to defaults", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config and modify some values - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // First set some non-default values - err = service.UpdateAppConfigValues(t.Context(), - "appName", "Custom App", - "sessionDuration", "120", - ) - require.NoError(t, err) - - // Create update DTO with empty values to reset to defaults - input := dto.AppConfigUpdateDto{ - AppName: "", // Should reset to default "Pocket ID" - SessionDuration: "", // Should reset to default "60" - } - - // Update config - updatedVars, err := service.UpdateAppConfig(t.Context(), input) - require.NoError(t, err) - - // Verify returned updated variables (they should be empty strings in DB) - var foundAppName, foundSessionDuration bool - for _, v := range updatedVars { - switch v.Key { - case "appName": - require.Equal(t, "Pocket ID", v.Value) // Returns the default value - foundAppName = true - case "sessionDuration": - require.Equal(t, "60", v.Value) // Returns the default value - foundSessionDuration = true - } - } - require.True(t, foundAppName) - require.True(t, foundSessionDuration) - - // Verify in-memory config was reset to defaults - config := service.GetDbConfig() - require.Equal(t, "Pocket ID", config.AppName.Value) // Default value - require.Equal(t, "60", config.SessionDuration.Value) // Default value - - // Verify database was updated with empty values - for _, key := range []string{"appName", "sessionDuration"} { - var loaded model.AppConfigVariable - err = db.Where("key = ?", key).First(&loaded).Error - require.NoErrorf(t, err, "Failed to load DB value for key '%s'", key) - require.Emptyf(t, loaded.Value, "Loaded value for key '%s' is not empty", key) - } - }) - - t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) { - // Save the original state and restore it after the test - originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled - defer func() { - common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled - }() - - // Disable UI config - common.EnvConfig.UiConfigDisabled = true - - db := testutils.NewDatabaseForTest(t) - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Try to update config - _, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{ - AppName: "Should Not Update", - }) - - // Should get a UiConfigDisabledError - require.Error(t, err) - var uiConfigDisabledErr *common.UiConfigDisabledError - require.ErrorAs(t, err, &uiConfigDisabledErr) - }) -} diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go index 7a2290e4..f45cac89 100644 --- a/backend/internal/service/audit_log_service.go +++ b/backend/internal/service/audit_log_service.go @@ -6,6 +6,7 @@ import ( "log/slog" userAgentParser "github.com/mileusna/useragent" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils/email" @@ -14,17 +15,17 @@ import ( type AuditLogService struct { db *gorm.DB - appConfigService *AppConfigService emailService *EmailService geoliteService *GeoLiteService + appConfigService *appconfig.AppConfigService } -func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService { +func NewAuditLogService(db *gorm.DB, emailService *EmailService, geoliteService *GeoLiteService, appConfigService *appconfig.AppConfigService) *AuditLogService { return &AuditLogService{ db: db, - appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService, + appConfigService: appConfigService, } } @@ -64,7 +65,8 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, } // CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before -func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog { +// emailLoginNotificationEnabled gates whether the notification email is sent, so the caller decides using the config it already loaded +func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, emailLoginNotificationEnabled bool) model.AuditLog { createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx) if !ok { // At this point the transaction has been canceled already, and error has been logged @@ -90,15 +92,22 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres } // If the user hasn't logged in from the same device before and email notifications are enabled, send an email - if s.appConfigService.GetDbConfig().EmailLoginNotificationEnabled.IsTrue() && count <= 1 { + if emailLoginNotificationEnabled && count <= 1 { go func() { // This runs in background, so use a context without cancellation (or it would be stopped when the request ends) // We still want to have a context derived from the request's to carry over tracing info innerCtx := context.WithoutCancel(ctx) + // This runs after the request has completed, so we resolve the current config rather than threading the request's snapshot into the goroutine + dbConfig, innerErr := s.appConfigService.GetConfig(innerCtx) + if innerErr != nil { + slog.ErrorContext(innerCtx, "Failed to load app configuration to send notification email", slog.Any("error", innerErr)) + return + } + // Note we don't use the transaction here because this is running in background var user model.User - innerErr := s.db. + innerErr = s.db. WithContext(innerCtx). Where("id = ?", userID). First(&user). @@ -112,7 +121,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres return } - innerErr = SendEmail(innerCtx, s.emailService, email.Address{ + innerErr = SendEmail(innerCtx, s.emailService, dbConfig, email.Address{ Name: user.FullName(), Email: *user.Email, }, NewLoginTemplate, &NewLoginTemplateData{ diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index bd32bc38..21572adc 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -22,10 +22,12 @@ import ( "github.com/ory/fosite/compose" fositejwt "github.com/ory/fosite/token/jwt" "github.com/pocket-id/pocket-id/backend/internal/apikey" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/api" "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/oidc" @@ -40,7 +42,7 @@ import ( type TestService struct { db *gorm.DB jwtService *JwtService - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService ldapService *LdapService fileStorage storage.FileStorage appLockService *AppLockService @@ -54,7 +56,7 @@ const ( e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4" ) -func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) { +func NewTestService(db *gorm.DB, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) { s := &TestService{ db: db, appConfigService: appConfigService, @@ -624,8 +626,8 @@ func (s *TestService) ResetApplicationImages(ctx context.Context) error { } func (s *TestService) ResetAppConfig(ctx context.Context) error { - // Reset all app config variables to their default values in the database - err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error + // Reset all application configuration values through the singleton actor + _, err := s.appConfigService.UpdateAppConfig(ctx, dto.AppConfigUpdateDto{}) if err != nil { return err } @@ -646,12 +648,6 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error { // The instance ID is loaded once at startup, so we also set it directly on the JWT service so it takes effect immediately s.jwtService.instanceID = testInstanceID - // Reload the app config from the database after resetting the values - err = s.appConfigService.LoadDbConfig(ctx) - if err != nil { - return err - } - // Reload the JWK if err := s.jwtService.LoadOrGenerateKey(ctx); err != nil { return err @@ -667,50 +663,39 @@ func (s *TestService) ResetLock(ctx context.Context) error { // SyncLdap triggers an LDAP synchronization func (s *TestService) SyncLdap(ctx context.Context) error { - return s.ldapService.SyncAll(ctx) + dbConfig, err := s.appConfigService.GetConfig(ctx) + if err != nil { + return fmt.Errorf("error loading app configuration: %w", err) + } + return s.ldapService.SyncAll(ctx, dbConfig) } -// SetLdapTestConfig writes the test LDAP config variables directly to the database. +// SetLdapTestConfig updates the LDAP configuration used by the end-to-end test server func (s *TestService) SetLdapTestConfig(ctx context.Context) error { - err := s.db.Transaction(func(tx *gorm.DB) error { - ldapConfigs := map[string]string{ - "ldapUrl": "ldap://lldap:3890", - "ldapBindDn": "uid=admin,ou=people,dc=pocket-id,dc=org", - "ldapBindPassword": "admin_password", - "ldapBase": "dc=pocket-id,dc=org", - "ldapUserSearchFilter": "(objectClass=person)", - "ldapUserGroupSearchFilter": "(objectClass=groupOfNames)", - "ldapSkipCertVerify": "true", - "ldapAttributeUserUniqueIdentifier": "uuid", - "ldapAttributeUserUsername": "uid", - "ldapAttributeUserEmail": "mail", - "ldapAttributeUserFirstName": "givenName", - "ldapAttributeUserLastName": "sn", - "ldapAttributeGroupUniqueIdentifier": "uuid", - "ldapAttributeGroupName": "uid", - "ldapAttributeGroupMember": "member", - "ldapAdminGroupName": "admin_group", - "ldapSoftDeleteUsers": "true", - "ldapEnabled": "true", - } - - for key, value := range ldapConfigs { - configVar := model.AppConfigVariable{Key: key, Value: value} - if err := tx.Create(&configVar).Error; err != nil { - return fmt.Errorf("failed to create config variable '%s': %w", key, err) - } - } - return nil - }) - + err := s.appConfigService.UpdateAppConfigValues(ctx, + "ldapUrl", "ldap://lldap:3890", + "ldapBindDn", "uid=admin,ou=people,dc=pocket-id,dc=org", + "ldapBindPassword", "admin_password", + "ldapBase", "dc=pocket-id,dc=org", + "ldapUserSearchFilter", "(objectClass=person)", + "ldapUserGroupSearchFilter", "(objectClass=groupOfNames)", + "ldapSkipCertVerify", "true", + "ldapAttributeUserUniqueIdentifier", "uuid", + "ldapAttributeUserUsername", "uid", + "ldapAttributeUserEmail", "mail", + "ldapAttributeUserFirstName", "givenName", + "ldapAttributeUserLastName", "sn", + "ldapAttributeGroupUniqueIdentifier", "uuid", + "ldapAttributeGroupName", "uid", + "ldapAttributeGroupMember", "member", + "ldapAdminGroupName", "admin_group", + "ldapSoftDeleteUsers", "true", + "ldapEnabled", "true", + ) if err != nil { return fmt.Errorf("failed to set LDAP test config: %w", err) } - if err := s.appConfigService.LoadDbConfig(ctx); err != nil { - return fmt.Errorf("failed to load app config: %w", err) - } - return nil } diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 19e671f9..98ac360a 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -13,19 +13,19 @@ import ( "github.com/italypaleale/go-kit/emailer" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils/email" ) type EmailService struct { - appConfigService *AppConfigService - db *gorm.DB - htmlTemplates map[string]*htemplate.Template - textTemplates map[string]*ttemplate.Template + db *gorm.DB + htmlTemplates map[string]*htemplate.Template + textTemplates map[string]*ttemplate.Template } -func NewEmailService(db *gorm.DB, appConfigService *AppConfigService) (*EmailService, error) { +func NewEmailService(db *gorm.DB) (*EmailService, error) { htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths) if err != nil { return nil, fmt.Errorf("prepare html templates: %w", err) @@ -37,14 +37,13 @@ func NewEmailService(db *gorm.DB, appConfigService *AppConfigService) (*EmailSer } return &EmailService{ - appConfigService: appConfigService, - db: db, - htmlTemplates: htmlTemplates, - textTemplates: textTemplates, + db: db, + htmlTemplates: htmlTemplates, + textTemplates: textTemplates, }, nil } -func (srv *EmailService) SendTestEmail(ctx context.Context, recipientUserId string) error { +func (srv *EmailService) SendTestEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, recipientUserId string) error { var user model.User err := srv.db. WithContext(ctx). @@ -58,18 +57,18 @@ func (srv *EmailService) SendTestEmail(ctx context.Context, recipientUserId stri return &common.UserEmailNotSetError{} } - return SendEmail(ctx, srv, + return SendEmail(ctx, srv, dbConfig, email.Address{ Email: *user.Email, Name: user.FullName(), }, TestTemplate, nil) } -func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error { - dbConfig := srv.appConfigService.GetDbConfig() +// SendEmail sends an email using the provided application configuration +func SendEmail[V any](ctx context.Context, srv *EmailService, dbConfig *appconfig.AppConfigModel, toEmail email.Address, template email.Template[V], tData *V) error { data := &email.TemplateData[V]{ - AppName: dbConfig.AppName.Value, + AppName: dbConfig.AppName.String(), LogoURL: common.EnvConfig.AppURL + "/api/application-images/email", Data: tData, } @@ -102,7 +101,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr } // getEmailer builds an emailer.Emailer from the current app config. -func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *model.AppConfig) (emailer.Emailer, error) { +func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *appconfig.AppConfigModel) (emailer.Emailer, error) { // We support SMTP only (for now) connString, err := smtpConnString(dbConfig) if err != nil { @@ -116,8 +115,8 @@ func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *model.AppConf // smtpConnString builds the SMTP connection string that go-kit's emailer expects: // smtp://:@:?fromAddress=
&fromName=&tls=&insecureSkipVerify= -func smtpConnString(dbConfig *model.AppConfig) (string, error) { - host := dbConfig.SmtpHost.Value +func smtpConnString(dbConfig *appconfig.AppConfigModel) (string, error) { + host := dbConfig.SmtpHost.String() if host == "" { return "", errors.New("SMTP host is not configured") } @@ -126,28 +125,28 @@ func smtpConnString(dbConfig *model.AppConfig) (string, error) { Scheme: "smtp", Host: host, } - port := dbConfig.SmtpPort.Value + port := dbConfig.SmtpPort.String() if port != "" { u.Host = net.JoinHostPort(host, port) } // Include credentials when set - smtpUser := dbConfig.SmtpUser.Value - smtpPassword := dbConfig.SmtpPassword.Value + smtpUser := dbConfig.SmtpUser.String() + smtpPassword := dbConfig.SmtpPassword.String() if smtpUser != "" || smtpPassword != "" { u.User = url.UserPassword(smtpUser, smtpPassword) } // TLS values from config: none, starttls, tls - tlsMode := dbConfig.SmtpTls.Value + tlsMode := dbConfig.SmtpTls.String() if tlsMode == "" { tlsMode = "none" } // Build the query string args q := url.Values{} - q.Set("fromAddress", dbConfig.SmtpFrom.Value) - q.Set("fromName", dbConfig.AppName.Value) + q.Set("fromAddress", dbConfig.SmtpFrom.String()) + q.Set("fromName", dbConfig.AppName.String()) q.Set("tls", tlsMode) if dbConfig.SmtpSkipCertVerify.IsTrue() { q.Set("insecureSkipVerify", "true") diff --git a/backend/internal/service/import_service_test.go b/backend/internal/service/import_service_test.go index a53fabf0..b793bafc 100644 --- a/backend/internal/service/import_service_test.go +++ b/backend/internal/service/import_service_test.go @@ -54,12 +54,14 @@ func seedActorHostSchema(t *testing.T, db *gorm.DB) { func requireActorHostSchemaPreserved(t *testing.T, db *gorm.DB) { t.Helper() var tableRows int64 - require.NoError(t, db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error) + err := db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error + require.NoError(t, err) require.Equal(t, int64(1), tableRows, "francis_ tables and their rows must be preserved by an import") // The view is only valid if its backing table was preserved as well var viewCount int64 - require.NoError(t, db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error) + err = db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error + require.NoError(t, err) require.Equal(t, int64(1), viewCount, "francis_ views must be preserved by an import") } diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go index 07ce8b21..fcc926da 100644 --- a/backend/internal/service/jwt_service.go +++ b/backend/internal/service/jwt_service.go @@ -43,19 +43,18 @@ const ( ) type JwtService struct { - db *gorm.DB - envConfig *common.EnvConfigSchema - privateKey jwk.Key - keyId string - appConfigService *AppConfigService - instanceID string - jwksEncoded []byte + db *gorm.DB + envConfig *common.EnvConfigSchema + privateKey jwk.Key + keyId string + instanceID string + jwksEncoded []byte } -func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService) (*JwtService, error) { +func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string) (*JwtService, error) { service := &JwtService{} - err := service.init(ctx, db, instanceID, appConfigService, &common.EnvConfig) + err := service.init(ctx, db, instanceID, &common.EnvConfig) if err != nil { return nil, err } @@ -63,8 +62,7 @@ func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfi return service, nil } -func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) { - s.appConfigService = appConfigService +func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema) (err error) { s.envConfig = envConfig s.db = db s.instanceID = instanceID @@ -183,12 +181,11 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error { return nil } -func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) { - +func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) { now := time.Now() token, err := jwt.NewBuilder(). Subject(user.ID). - Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())). + Expiration(now.Add(sessionDuration)). IssuedAt(now). Issuer(s.envConfig.AppURL). JwtID(uuid.New().String()). diff --git a/backend/internal/service/jwt_service_test.go b/backend/internal/service/jwt_service_test.go index b0eae143..ca69836e 100644 --- a/backend/internal/service/jwt_service_test.go +++ b/backend/internal/service/jwt_service_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/instanceid" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -35,17 +36,17 @@ func newTestEnvConfig() *common.EnvConfigSchema { } } -func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *AppConfigService, envConfig *common.EnvConfigSchema) *JwtService { +func initJwtService(t *testing.T, db *gorm.DB, instanceID string, _ *appconfig.AppConfigService, envConfig *common.EnvConfigSchema) *JwtService { t.Helper() service := &JwtService{} - err := service.init(t.Context(), db, instanceID, appConfig, envConfig) + err := service.init(t.Context(), db, instanceID, envConfig) require.NoError(t, err, "Failed to initialize JWT service") return service } -func setupJwtService(t *testing.T, instanceID string, appConfig *AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) { +func setupJwtService(t *testing.T, instanceID string, appConfig *appconfig.AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) { t.Helper() db := testutils.NewDatabaseForTest(t) @@ -70,7 +71,7 @@ func newTestDbAndEnv(t *testing.T) (*gorm.DB, *common.EnvConfigSchema) { return testutils.NewDatabaseForTest(t), newTestEnvConfig() } -func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, key jwk.Key) string { +func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, key jwk.Key) string { t.Helper() keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID) @@ -87,9 +88,7 @@ func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig * } func TestJwtService_Init(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes - }) + mockConfig := appconfig.NewTestAppConfigService(nil) t.Run("should generate new key when none exists", func(t *testing.T) { db := testutils.NewDatabaseForTest(t) @@ -192,9 +191,7 @@ func TestJwtService_Init(t *testing.T) { } func TestJwtService_GetPublicJWK(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes - }) + mockConfig := appconfig.NewTestAppConfigService(nil) db := testutils.NewDatabaseForTest(t) mockEnvConfig := newTestEnvConfig() instanceID := newInstanceID(t, db) @@ -310,9 +307,8 @@ func TestJwtService_GetPublicJWK(t *testing.T) { } func TestGenerateVerifyAccessToken(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes - }) + const sessionDuration = time.Hour + mockConfig := appconfig.NewTestAppConfigService(nil) db, envConfig := newTestDbAndEnv(t) instanceID := newInstanceID(t, db) @@ -325,7 +321,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: false, } - tokenString, err := service.GenerateAccessToken(user, "") + tokenString, err := service.GenerateAccessToken(user, "", sessionDuration) require.NoError(t, err, "Failed to generate access token") assert.NotEmpty(t, tokenString, "Token should not be empty") @@ -366,7 +362,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: true, } - tokenString, err := service.GenerateAccessToken(adminUser, "") + tokenString, err := service.GenerateAccessToken(adminUser, "", sessionDuration) require.NoError(t, err, "Failed to generate access token") claims, err := service.VerifyAccessToken(tokenString) @@ -389,7 +385,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { Base: model.Base{ID: "user-with-auth-method"}, } - tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant) + tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant, sessionDuration) require.NoError(t, err, "Failed to generate access token") claims, err := service.VerifyAccessToken(tokenString) @@ -400,29 +396,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) { assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match") }) - t.Run("uses session duration from config", func(t *testing.T) { - customMockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes - }) - service, _, _ := setupJwtService(t, instanceID, customMockConfig) - - user := model.User{ - Base: model.Base{ID: "user456"}, - } - - tokenString, err := service.GenerateAccessToken(user, "") - require.NoError(t, err, "Failed to generate access token") - - claims, err := service.VerifyAccessToken(tokenString) - require.NoError(t, err, "Failed to verify generated token") - - expectedExp := time.Now().Add(30 * time.Minute) - expiration, ok := claims.Expiration() - assert.True(t, ok, "Expiration not found in token") - timeDiff := expectedExp.Sub(expiration).Minutes() - assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 30 minutes") - }) - t.Run("works with Ed25519 keys", func(t *testing.T) { origKeyID := createEdDSAKeyJWK(t, db, instanceID, envConfig, mockConfig) service := initJwtService(t, db, instanceID, mockConfig, envConfig) @@ -437,7 +410,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: true, } - tokenString, err := service.GenerateAccessToken(user, "") + tokenString, err := service.GenerateAccessToken(user, "", sessionDuration) require.NoError(t, err, "Failed to generate access token with Ed25519 key") assert.NotEmpty(t, tokenString, "Token should not be empty") @@ -475,7 +448,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: true, } - tokenString, err := service.GenerateAccessToken(user, "") + tokenString, err := service.GenerateAccessToken(user, "", sessionDuration) require.NoError(t, err, "Failed to generate access token with ECDSA key") assert.NotEmpty(t, tokenString, "Token should not be empty") @@ -513,7 +486,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: true, } - tokenString, err := service.GenerateAccessToken(user, "") + tokenString, err := service.GenerateAccessToken(user, "", sessionDuration) require.NoError(t, err, "Failed to generate access token with RSA key") assert.NotEmpty(t, tokenString, "Token should not be empty") @@ -582,7 +555,7 @@ func TestTokenTypeValidator(t *testing.T) { }) } -func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string { +func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, privateKeyRaw any) string { t.Helper() privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "") @@ -597,7 +570,7 @@ var ( rsaKeyPrecomputeOnce sync.Once ) -func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string { +func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string { t.Helper() rsaKeyPrecomputeOnce.Do(func() { @@ -612,7 +585,7 @@ func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig return importKey(t, db, instanceID, envConfig, appConfig, rsaKeyPrecomputed) } -func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string { +func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string { t.Helper() // Generate a new P-256 ECDSA key @@ -624,7 +597,7 @@ func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig * } // Helper function to create an Ed25519 key and save it as JWK -func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string { +func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string { t.Helper() // Generate a new Ed25519 key pair diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index 73392c61..34f6a3f4 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -18,6 +18,7 @@ import ( "github.com/go-ldap/ldap/v3" "github.com/google/uuid" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" "golang.org/x/text/unicode/norm" @@ -29,13 +30,12 @@ import ( ) type LdapService struct { - db *gorm.DB - httpClient *http.Client - appConfigService *AppConfigService - userService *UserService - groupService *UserGroupService - fileStorage storage.FileStorage - clientFactory func() (ldapClient, error) + db *gorm.DB + httpClient *http.Client + userService *UserService + groupService *UserGroupService + fileStorage storage.FileStorage + clientFactory func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) } type savePicture struct { @@ -69,29 +69,26 @@ type ldapClient interface { Close() error } -func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService { +func NewLdapService(db *gorm.DB, httpClient *http.Client, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService { service := &LdapService{ - db: db, - httpClient: httpClient, - appConfigService: appConfigService, - userService: userService, - groupService: groupService, - fileStorage: fileStorage, + db: db, + httpClient: httpClient, + userService: userService, + groupService: groupService, + fileStorage: fileStorage, } service.clientFactory = service.createClient return service } -func (s *LdapService) createClient() (ldapClient, error) { - dbConfig := s.appConfigService.GetDbConfig() - +func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) { if !dbConfig.LdapEnabled.IsTrue() { return nil, fmt.Errorf("LDAP is not enabled") } // Setup LDAP connection - client, err := ldap.DialURL(dbConfig.LdapUrl.Value, ldap.DialWithTLSConfig(&tls.Config{ + client, err := ldap.DialURL(dbConfig.LdapUrl.String(), ldap.DialWithTLSConfig(&tls.Config{ InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec })) if err != nil { @@ -99,23 +96,24 @@ func (s *LdapService) createClient() (ldapClient, error) { } // Bind as service account - err = client.Bind(dbConfig.LdapBindDn.Value, dbConfig.LdapBindPassword.Value) + err = client.Bind(dbConfig.LdapBindDn.String(), dbConfig.LdapBindPassword.String()) if err != nil { return nil, fmt.Errorf("failed to bind to LDAP: %w", err) } return client, nil } -func (s *LdapService) SyncAll(ctx context.Context) error { +// SyncAll synchronizes LDAP using the provided application configuration +func (s *LdapService) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error { // Setup LDAP connection - client, err := s.clientFactory() + client, err := s.clientFactory(dbConfig) if err != nil { return fmt.Errorf("failed to create LDAP client: %w", err) } defer client.Close() // First, we fetch all users and group from LDAP, which is our "desired state" - desiredState, err := s.fetchDesiredState(ctx, client) + desiredState, err := s.fetchDesiredState(ctx, client, dbConfig) if err != nil { return fmt.Errorf("failed to fetch LDAP state: %w", err) } @@ -128,13 +126,13 @@ func (s *LdapService) SyncAll(ctx context.Context) error { defer tx.Rollback() // Reconcile users - savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs) + savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs, dbConfig) if err != nil { return fmt.Errorf("failed to sync users: %w", err) } // Reconcile groups - err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs) + err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs, dbConfig) if err != nil { return fmt.Errorf("failed to sync groups: %w", err) } @@ -167,15 +165,15 @@ func (s *LdapService) SyncAll(ctx context.Context) error { return nil } -func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) { +func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (ldapDesiredState, error) { // Fetch users first so we can use their DNs when resolving group members - users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client) + users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client, dbConfig) if err != nil { return ldapDesiredState{}, err } // Then fetch groups to complete the desired LDAP state snapshot - groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN) + groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN, dbConfig) if err != nil { return ldapDesiredState{}, err } @@ -183,7 +181,7 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) // Apply user admin flags from the desired group membership snapshot. // This intentionally uses the configured group member attribute rather than // relying on a user-side reverse-membership attribute such as memberOf. - s.applyAdminGroupMembership(users, groups) + s.applyAdminGroupMembership(users, groups, dbConfig) return ldapDesiredState{ users: users, @@ -193,15 +191,14 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) }, nil } -func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) { - dbConfig := s.appConfigService.GetDbConfig() - if dbConfig.LdapAdminGroupName.Value == "" { +func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup, dbConfig *appconfig.AppConfigModel) { + if dbConfig.LdapAdminGroupName == "" { return } adminUsernames := make(map[string]struct{}) for _, group := range desiredGroups { - if group.input.Name != dbConfig.LdapAdminGroupName.Value { + if group.input.Name != string(dbConfig.LdapAdminGroupName) { continue } @@ -216,21 +213,19 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, } } -func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) { - dbConfig := s.appConfigService.GetDbConfig() - +func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string, dbConfig *appconfig.AppConfigModel) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) { // Query LDAP for all groups we want to manage searchAttrs := []string{ - dbConfig.LdapAttributeGroupName.Value, - dbConfig.LdapAttributeGroupUniqueIdentifier.Value, - dbConfig.LdapAttributeGroupMember.Value, + dbConfig.LdapAttributeGroupName.String(), + dbConfig.LdapAttributeGroupUniqueIdentifier.String(), + dbConfig.LdapAttributeGroupMember.String(), } searchReq := ldap.NewSearchRequest( - dbConfig.LdapBase.Value, + dbConfig.LdapBase.String(), ldap.ScopeWholeSubtree, 0, 0, 0, false, - dbConfig.LdapUserGroupSearchFilter.Value, + dbConfig.LdapUserGroupSearchFilter.String(), searchAttrs, []ldap.Control{}, ) @@ -244,21 +239,21 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries)) for _, value := range result.Entries { - ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value)) + ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.String())) // Skip groups without a valid LDAP ID if ldapID == "" { - slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value)) + slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.String())) continue } ldapGroupIDs[ldapID] = struct{}{} // Get group members and add to the correct Group - groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value) + groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.String()) memberUsernames := make([]string, 0, len(groupMembers)) for _, member := range groupMembers { - username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN) + username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN, dbConfig.LdapAttributeUserUsername.String()) if username == "" { continue } @@ -267,8 +262,8 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient } syncGroup := dto.UserGroupCreateDto{ - Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), - FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), + Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()), + FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()), LdapID: ldapID, } dto.Normalize(&syncGroup) @@ -289,28 +284,26 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient return desiredGroups, ldapGroupIDs, nil } -func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) { - dbConfig := s.appConfigService.GetDbConfig() - +func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) { // Query LDAP for all users we want to manage searchAttrs := []string{ "sn", "cn", - dbConfig.LdapAttributeUserUniqueIdentifier.Value, - dbConfig.LdapAttributeUserUsername.Value, - dbConfig.LdapAttributeUserEmail.Value, - dbConfig.LdapAttributeUserFirstName.Value, - dbConfig.LdapAttributeUserLastName.Value, - dbConfig.LdapAttributeUserProfilePicture.Value, - dbConfig.LdapAttributeUserDisplayName.Value, + dbConfig.LdapAttributeUserUniqueIdentifier.String(), + dbConfig.LdapAttributeUserUsername.String(), + dbConfig.LdapAttributeUserEmail.String(), + dbConfig.LdapAttributeUserFirstName.String(), + dbConfig.LdapAttributeUserLastName.String(), + dbConfig.LdapAttributeUserProfilePicture.String(), + dbConfig.LdapAttributeUserDisplayName.String(), } // Filters must start and finish with ()! searchReq := ldap.NewSearchRequest( - dbConfig.LdapBase.Value, + dbConfig.LdapBase.String(), ldap.ScopeWholeSubtree, 0, 0, 0, false, - dbConfig.LdapUserSearchFilter.Value, + dbConfig.LdapUserSearchFilter.String(), searchAttrs, []ldap.Control{}, ) @@ -326,28 +319,28 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries)) for _, value := range result.Entries { - username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)) + username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String())) if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" { usernamesByDN[normalizedDN] = username } - ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value)) + ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.String())) // Skip users without a valid LDAP ID if ldapID == "" { - slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value)) + slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.String())) continue } ldapUserIDs[ldapID] = struct{}{} newUser := dto.UserCreateDto{ - Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), - Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)), + Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()), + Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.String())), EmailVerified: true, - FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), - LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), - DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), + FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.String()), + LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.String()), + DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.String()), // Admin status is computed after groups are loaded so it can use the // configured group member attribute instead of a hard-coded memberOf. IsAdmin: false, @@ -369,16 +362,14 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) desiredUsers = append(desiredUsers, ldapDesiredUser{ ldapID: ldapID, input: newUser, - picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value), + picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.String()), }) } return desiredUsers, ldapUserIDs, usernamesByDN, nil } -func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string { - dbConfig := s.appConfigService.GetDbConfig() - +func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string, usernameAttr string) string { // First try the DN cache we built while loading users username, exists := usernamesByDN[normalizeLDAPDN(member)] if exists && username != "" { @@ -386,14 +377,15 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda } // Then try to extract the username directly from the DN - username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member) + username = getDNProperty(usernameAttr, member) if username != "" { return norm.NFC.String(username) } // posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value // that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408 - if _, err := ldap.ParseDN(member); err != nil { + _, err := ldap.ParseDN(member) + if err != nil { return norm.NFC.String(member) } @@ -403,7 +395,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda ldap.ScopeBaseObject, 0, 0, 0, false, "(objectClass=*)", - []string{dbConfig.LdapAttributeUserUsername.Value}, + []string{usernameAttr}, []ldap.Control{}, ) @@ -413,7 +405,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda return "" } - username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value) + username = userResult.Entries[0].GetAttributeValue(usernameAttr) if username == "" { slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member)) return "" @@ -422,7 +414,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda return norm.NFC.String(username) } -func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error { +func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) error { // Load the current LDAP-managed state from the database ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx) if err != nil { @@ -462,7 +454,7 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG continue } - _, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx) + _, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx, dbConfig) if err != nil { return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err) } @@ -498,9 +490,7 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG } //nolint:gocognit -func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) { - dbConfig := s.appConfigService.GetDbConfig() - +func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) (savePictures []savePicture, deleteFiles []string, err error) { // Load the current LDAP-managed state from the database ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx) if err != nil { @@ -531,7 +521,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs userID := databaseUser.ID if databaseUser.ID == "" { - createdUser, err := s.userService.CreateUserInternal(ctx, desiredUser.input, true, tx) + createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx, dbConfig) if errors.Is(err, &common.AlreadyInUseError{}) { slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err)) continue @@ -542,7 +532,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs userID = createdUser.ID ldapUsersByID[desiredUser.ldapID] = createdUser } else { - _, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx) + _, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx, dbConfig) if errors.Is(err, &common.AlreadyInUseError{}) { slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err)) continue @@ -581,7 +571,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs continue } - err = s.userService.deleteUserInternal(ctx, tx, user.ID, true) + err = s.userService.deleteUserInternal(ctx, tx, user.ID, true, dbConfig) if err != nil { if _, ok := errors.AsType[*common.LdapUserUpdateError](err); ok { return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username) diff --git a/backend/internal/service/ldap_service_test.go b/backend/internal/service/ldap_service_test.go index d340cc74..6f935fc9 100644 --- a/backend/internal/service/ldap_service_test.go +++ b/backend/internal/service/ldap_service_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/storage" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -109,7 +110,8 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) { LdapID: &oldGroupLdapID, }).Error) - require.NoError(t, service.SyncAll(t.Context())) + err := service.SyncAll(t.Context(), defaultTestLDAPAppConfig()) + require.NoError(t, err) var alice model.User require.NoError(t, db.First(&alice, "ldap_id = ?", aliceLdapID).Error) @@ -144,8 +146,8 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) { // Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408. func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) { appCfg := defaultTestLDAPAppConfig() - appCfg.LdapUserGroupSearchFilter = model.AppConfigVariable{Value: "(objectClass=posixGroup)"} - appCfg.LdapAttributeGroupMember = model.AppConfigVariable{Value: "memberUid"} + appCfg.LdapUserGroupSearchFilter = "(objectClass=posixGroup)" + appCfg.LdapAttributeGroupMember = "memberUid" service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient( ldapSearchResult( @@ -175,7 +177,8 @@ func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) { ), )) - require.NoError(t, service.SyncAll(t.Context())) + err := service.SyncAll(t.Context(), appCfg) + require.NoError(t, err) var group model.UserGroup require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", "g-users").Error) @@ -217,7 +220,8 @@ func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) { ), )) - require.NoError(t, service.SyncAll(t.Context())) + err := service.SyncAll(t.Context(), defaultTestLDAPAppConfig()) + require.NoError(t, err) var users []model.User require.NoError(t, db.Find(&users, "ldap_id = ?", "u-dup").Error) @@ -237,7 +241,7 @@ func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) { func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) { tests := []struct { name string - appConfig *model.AppConfig + appConfig *appconfig.AppConfigModel groupEntry *ldap.Entry groupName string groupLookup string @@ -255,10 +259,10 @@ func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) { }, { name: "configured group name attribute differs from DN RDN", - appConfig: func() *model.AppConfig { + appConfig: func() *appconfig.AppConfigModel { cfg := defaultTestLDAPAppConfig() - cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"} - cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"} + cfg.LdapAttributeGroupName = "displayName" + cfg.LdapAdminGroupName = "pocketid.admin" return cfg }(), groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{ @@ -288,7 +292,8 @@ func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) { ldapSearchResult(tt.groupEntry), )) - require.NoError(t, service.SyncAll(t.Context())) + err := service.SyncAll(t.Context(), tt.appConfig) + require.NoError(t, err) var user model.User require.NoError(t, db.First(&user, "ldap_id = ?", "u-testadmin").Error) @@ -308,7 +313,7 @@ func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client) } -func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) { +func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.AppConfigModel, client ldapClient) (*LdapService, *gorm.DB) { t.Helper() db := testutils.NewDatabaseForTest(t) @@ -316,48 +321,45 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf fileStorage, err := storage.NewDatabaseStorage(db) require.NoError(t, err) - appConfig := NewTestAppConfigService(appConfigModel) - - groupService := NewUserGroupService(db, appConfig, nil) + groupService := NewUserGroupService(db, nil) userService := NewUserService( db, nil, nil, nil, - appConfig, NewCustomClaimService(db), NewAppImagesService(map[string]string{}, fileStorage), nil, fileStorage, ) - service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage) - service.clientFactory = func() (ldapClient, error) { + service := NewLdapService(db, &http.Client{}, userService, groupService, fileStorage) + service.clientFactory = func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) { return client, nil } return service, db } -func defaultTestLDAPAppConfig() *model.AppConfig { - return &model.AppConfig{ - RequireUserEmail: model.AppConfigVariable{Value: "false"}, - LdapEnabled: model.AppConfigVariable{Value: "true"}, - LdapBase: model.AppConfigVariable{Value: "dc=example,dc=com"}, - LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"}, - LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"}, - LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"}, - LdapAttributeUserUsername: model.AppConfigVariable{Value: "uid"}, - LdapAttributeUserEmail: model.AppConfigVariable{Value: "mail"}, - LdapAttributeUserFirstName: model.AppConfigVariable{Value: "givenName"}, - LdapAttributeUserLastName: model.AppConfigVariable{Value: "sn"}, - LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "displayName"}, - LdapAttributeUserProfilePicture: model.AppConfigVariable{Value: "jpegPhoto"}, - LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"}, - LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"}, - LdapAttributeGroupName: model.AppConfigVariable{Value: "cn"}, - LdapAdminGroupName: model.AppConfigVariable{Value: "admins"}, - LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"}, +func defaultTestLDAPAppConfig() *appconfig.AppConfigModel { + return &appconfig.AppConfigModel{ + RequireUserEmail: "false", + LdapEnabled: "true", + LdapBase: "dc=example,dc=com", + LdapUserSearchFilter: "(objectClass=person)", + LdapUserGroupSearchFilter: "(objectClass=groupOfNames)", + LdapAttributeUserUniqueIdentifier: "entryUUID", + LdapAttributeUserUsername: "uid", + LdapAttributeUserEmail: "mail", + LdapAttributeUserFirstName: "givenName", + LdapAttributeUserLastName: "sn", + LdapAttributeUserDisplayName: "displayName", + LdapAttributeUserProfilePicture: "jpegPhoto", + LdapAttributeGroupMember: "member", + LdapAttributeGroupUniqueIdentifier: "entryUUID", + LdapAttributeGroupName: "cn", + LdapAdminGroupName: "admins", + LdapSoftDeleteUsers: "true", } } diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index d667e107..5f259a48 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -37,11 +37,10 @@ const ( ) type OidcService struct { - db *gorm.DB - jwtService *JwtService - appConfigService *AppConfigService - previewBuilder oidcClientPreviewBuilder - scimService *ScimService + db *gorm.DB + jwtService *JwtService + previewBuilder oidcClientPreviewBuilder + scimService *ScimService httpClient *http.Client fileStorage storage.FileStorage @@ -54,20 +53,18 @@ type oidcClientPreviewBuilder interface { func NewOidcService( db *gorm.DB, jwtService *JwtService, - appConfigService *AppConfigService, previewBuilder oidcClientPreviewBuilder, scimService *ScimService, httpClient *http.Client, fileStorage storage.FileStorage, ) (s *OidcService, err error) { s = &OidcService{ - db: db, - jwtService: jwtService, - appConfigService: appConfigService, - previewBuilder: previewBuilder, - scimService: scimService, - httpClient: httpClient, - fileStorage: fileStorage, + db: db, + jwtService: jwtService, + previewBuilder: previewBuilder, + scimService: scimService, + httpClient: httpClient, + fileStorage: fileStorage, } return s, nil diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go index 5c9b4435..2d769e71 100644 --- a/backend/internal/service/oidc_service_test.go +++ b/backend/internal/service/oidc_service_test.go @@ -454,7 +454,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { func TestOidcService_CreateClient_withDescription(t *testing.T) { db := testutils.NewDatabaseForTest(t) - s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil) + s, err := NewOidcService(db, nil, nil, nil, nil, nil) require.NoError(t, err) description := "A test client description" @@ -479,7 +479,7 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) { func TestOidcService_CreateClient_withoutDescription(t *testing.T) { db := testutils.NewDatabaseForTest(t) - s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil) + s, err := NewOidcService(db, nil, nil, nil, nil, nil) require.NoError(t, err) input := dto.OidcClientCreateDto{ @@ -501,7 +501,7 @@ func TestOidcService_CreateClient_withoutDescription(t *testing.T) { func TestOidcService_UpdateClient_description(t *testing.T) { db := testutils.NewDatabaseForTest(t) - s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil) + s, err := NewOidcService(db, nil, nil, nil, nil, nil) require.NoError(t, err) // Create a client without a description diff --git a/backend/internal/service/one_time_access_service.go b/backend/internal/service/one_time_access_service.go index b22a5871..af561ee3 100644 --- a/backend/internal/service/one_time_access_service.go +++ b/backend/internal/service/one_time_access_service.go @@ -3,11 +3,13 @@ package service import ( "context" "errors" + "fmt" "log/slog" "net/url" "strings" "time" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -18,38 +20,34 @@ import ( ) type OneTimeAccessService struct { - db *gorm.DB - userService *UserService - appConfigService *AppConfigService - jwtService *JwtService - auditLogService *AuditLogService - emailService *EmailService + db *gorm.DB + userService *UserService + jwtService *JwtService + auditLogService *AuditLogService + emailService *EmailService } -func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService { +func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *OneTimeAccessService { return &OneTimeAccessService{ - db: db, - userService: userService, - appConfigService: appConfigService, - jwtService: jwtService, - auditLogService: auditLogService, - emailService: emailService, + db: db, + userService: userService, + jwtService: jwtService, + auditLogService: auditLogService, + emailService: emailService, } } -func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error { - isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue() - if isDisabled { +func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error { + if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() { return &common.OneTimeAccessDisabledError{} } - _, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false) + _, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig) return err } -func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) { - isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() - if isDisabled { +func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) { + if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() { return "", &common.OneTimeAccessDisabledError{} } @@ -62,7 +60,7 @@ func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ct return "", err } - deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true) + deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true, dbConfig) if err != nil { return "", err } else if deviceToken == nil { @@ -72,7 +70,7 @@ func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ct return *deviceToken, nil } -func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) { +func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool, dbConfig *appconfig.AppConfigModel) (*string, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -110,7 +108,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath } - errInternal := SendEmail(innerCtx, s.emailService, email.Address{ + errInternal := SendEmail(innerCtx, s.emailService, dbConfig, email.Address{ Name: user.FullName(), Email: *user.Email, }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ @@ -151,7 +149,7 @@ func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, use // Commit err = tx.Commit().Error if err != nil { - return "", err + return "", fmt.Errorf("error committing transaction: %w", err) } return token, nil @@ -171,7 +169,7 @@ func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Cont return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil } -func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) { +func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -198,7 +196,11 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t return model.User{}, "", &common.UserDisabledError{} } - accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User, AuthenticationMethodOneTimePassword) + accessToken, err := s.jwtService.GenerateAccessToken( + oneTimeAccessToken.User, + AuthenticationMethodOneTimePassword, + dbConfig.SessionDuration.AsDurationMinutes(), + ) if err != nil { return model.User{}, "", err } @@ -211,11 +213,16 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t return model.User{}, "", err } - s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx) + s.auditLogService.Create( + ctx, model.AuditLogEventOneTimeAccessTokenSignIn, + ipAddress, userAgent, + oneTimeAccessToken.User.ID, model.AuditLogData{}, + tx, + ) err = tx.Commit().Error if err != nil { - return model.User{}, "", err + return model.User{}, "", fmt.Errorf("error committing transaction: %w", err) } return oneTimeAccessToken.User, accessToken, nil diff --git a/backend/internal/service/one_time_access_service_test.go b/backend/internal/service/one_time_access_service_test.go index 8d2964fb..ff946043 100644 --- a/backend/internal/service/one_time_access_service_test.go +++ b/backend/internal/service/one_time_access_service_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -14,11 +15,11 @@ import ( func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) { db := testutils.NewDatabaseForTest(t) - appConfig := NewTestAppConfigService((&AppConfigService{}).getDefaultDbConfig()) + appConfig := appconfig.NewTestAppConfigService(nil) instanceID := newInstanceID(t, db) jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig()) - auditLogService := NewAuditLogService(db, appConfig, nil, &GeoLiteService{}) - oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil, appConfig) + auditLogService := NewAuditLogService(db, nil, &GeoLiteService{}, appConfig) + oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil) user := model.User{ Base: model.Base{ID: "disabled-user"}, @@ -35,7 +36,8 @@ func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) { } require.NoError(t, db.Create(&loginCode).Error) - exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), loginCode.Token, "", "", "") + dbConfig := appconfig.NewTestConfig(nil) + exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, loginCode.Token, "", "", "") var userDisabledErr *common.UserDisabledError require.ErrorAs(t, err, &userDisabledErr) diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 0e37a5ac..fe942250 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "gorm.io/gorm" @@ -15,13 +16,12 @@ import ( ) type UserGroupService struct { - db *gorm.DB - scimService *ScimService - appConfigService *AppConfigService + db *gorm.DB + scimService *ScimService } -func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService { - return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService} +func NewUserGroupService(db *gorm.DB, scimService *ScimService) *UserGroupService { + return &UserGroupService{db: db, scimService: scimService} } func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) { @@ -62,7 +62,7 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm. return group, err } -func (s *UserGroupService) Delete(ctx context.Context, id string) error { +func (s *UserGroupService) Delete(ctx context.Context, cfg *appconfig.AppConfigModel, id string) error { tx := s.db.Begin() defer func() { tx.Rollback() @@ -79,7 +79,7 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error { } // Disallow deleting the group if it is an LDAP group and LDAP is enabled - if group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { + if group.LdapID != nil && cfg.LdapEnabled.IsTrue() { return &common.LdapUserGroupUpdateError{} } @@ -122,10 +122,9 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro Preload("Users"). Create(&group). Error - if err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} - } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} + } else if err != nil { return model.UserGroup{}, err } @@ -136,13 +135,13 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro return group, nil } -func (s *UserGroupService) Update(ctx context.Context, id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) { +func (s *UserGroupService) Update(ctx context.Context, cfg *appconfig.AppConfigModel, id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) { tx := s.db.Begin() defer func() { tx.Rollback() }() - group, err = s.updateInternal(ctx, id, input, false, tx) + group, err = s.updateInternal(ctx, id, input, false, tx, cfg) if err != nil { return model.UserGroup{}, err } @@ -155,15 +154,17 @@ func (s *UserGroupService) Update(ctx context.Context, id string, input dto.User return group, nil } -func (s *UserGroupService) updateInternal(ctx context.Context, id string, input dto.UserGroupCreateDto, isLdapSync bool, tx *gorm.DB) (group model.UserGroup, err error) { +func (s *UserGroupService) updateInternal(ctx context.Context, id string, input dto.UserGroupCreateDto, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (group model.UserGroup, err error) { group, err = s.getInternal(ctx, id, tx) if err != nil { return model.UserGroup{}, err } // Disallow updating the group if it is an LDAP group and LDAP is enabled - if !isLdapSync && group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { - return model.UserGroup{}, &common.LdapUserGroupUpdateError{} + if !isLdapSync && group.LdapID != nil { + if cfg.LdapEnabled.IsTrue() { + return model.UserGroup{}, &common.LdapUserGroupUpdateError{} + } } group.Name = input.Name @@ -216,7 +217,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u // Fetch the users based on the userIds var users []model.User if len(userIds) > 0 { - err := tx. + err = tx. WithContext(ctx). Where("id IN (?)", userIds). Find(&users). diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 0f974e0d..34a1ee07 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -13,16 +13,17 @@ import ( "time" "github.com/google/uuid" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" "gorm.io/gorm" "gorm.io/gorm/clause" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" + "github.com/pocket-id/pocket-id/backend/internal/utils/email" profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image" ) @@ -31,20 +32,18 @@ type UserService struct { jwtService *JwtService auditLogService *AuditLogService emailService *EmailService - appConfigService *AppConfigService customClaimService *CustomClaimService appImagesService *AppImagesService scimService *ScimService fileStorage storage.FileStorage } -func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService { +func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService { return &UserService{ db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, - appConfigService: appConfigService, customClaimService: customClaimService, appImagesService: appImagesService, scimService: scimService, @@ -185,9 +184,9 @@ func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, f return nil } -func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDelete bool) error { +func (s *UserService) DeleteUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, allowLdapDelete bool) error { err := s.db.Transaction(func(tx *gorm.DB) error { - return s.deleteUserInternal(ctx, tx, userID, allowLdapDelete) + return s.deleteUserInternal(ctx, tx, userID, allowLdapDelete, dbConfig) }) if err != nil { return fmt.Errorf("failed to delete user '%s': %w", userID, err) @@ -203,9 +202,8 @@ func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDe return nil } -func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool) error { +func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool, cfg *appconfig.AppConfigModel) error { var user model.User - err := tx. WithContext(ctx). Where("id = ?", userID). @@ -217,8 +215,10 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI } // Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled - if !allowLdapDelete && !user.Disabled && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { - return &common.LdapUserUpdateError{} + if !allowLdapDelete && !user.Disabled && user.LdapID != nil { + if cfg.LdapEnabled.IsTrue() { + return &common.LdapUserUpdateError{} + } } err = tx.WithContext(ctx).Delete(&user).Error @@ -233,13 +233,13 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI return nil } -func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (model.User, error) { +func (s *UserService) CreateUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto) (model.User, error) { tx := s.db.Begin() defer func() { tx.Rollback() }() - user, err := s.CreateUserInternal(ctx, input, false, tx) + user, err := s.CreateUserInternal(ctx, dbConfig, input, false, tx) if err != nil { return model.User{}, err } @@ -252,8 +252,12 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) ( return user, nil } -func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) { - if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && input.Email == nil { +func (s *UserService) CreateUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) { + return s.createUserInternal(ctx, input, isLdapSync, tx, dbConfig) +} + +func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) { + if cfg.RequireUserEmail.IsTrue() && input.Email == nil { return model.User{}, &common.UserEmailNotSetError{} } @@ -313,13 +317,13 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea // Apply default groups and claims for new non-LDAP users if !isLdapSync { if len(input.UserGroupIds) == 0 { - err = s.applyDefaultGroups(ctx, &user, tx) + err = s.applyDefaultGroups(ctx, &user, tx, cfg) if err != nil { return model.User{}, err } } - err = s.applyDefaultCustomClaims(ctx, &user, tx) + err = s.applyDefaultCustomClaims(ctx, &user, tx, cfg) if err != nil { return model.User{}, err } @@ -332,11 +336,9 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea return user, nil } -func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error { - config := s.appConfigService.GetDbConfig() - +func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB, cfg *appconfig.AppConfigModel) error { var groupIDs []string - v := config.SignupDefaultUserGroupIDs.Value + v := cfg.SignupDefaultUserGroupIDs if v != "" && v != "[]" { err := json.Unmarshal([]byte(v), &groupIDs) if err != nil { @@ -396,11 +398,9 @@ func (s *UserService) touchUserGroups(ctx context.Context, tx *gorm.DB, ids []st Error } -func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error { - config := s.appConfigService.GetDbConfig() - +func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB, cfg *appconfig.AppConfigModel) error { var claims []dto.CustomClaimCreateDto - v := config.SignupDefaultCustomClaims.Value + v := cfg.SignupDefaultCustomClaims if v != "" && v != "[]" { err := json.Unmarshal([]byte(v), &claims) if err != nil { @@ -417,13 +417,13 @@ func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model. return nil } -func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) { +func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigModel, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) { tx := s.db.Begin() defer func() { tx.Rollback() }() - user, err := s.updateUserInternal(ctx, userID, updatedUser, updateOwnUser, isLdapSync, tx) + user, err := s.updateUserInternal(ctx, userID, updatedUser, updateOwnUser, isLdapSync, tx, cfg) if err != nil { return model.User{}, err } @@ -436,8 +436,8 @@ func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser return user, nil } -func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) { - if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && updatedUser.Email == nil { +func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) { + if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil { return model.User{}, &common.UserEmailNotSetError{} } @@ -453,8 +453,8 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd } // Check if this is an LDAP user and LDAP is enabled - isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() - allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() + isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue() + allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue() if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) { // Restricted update: Only locale can be changed when: @@ -472,7 +472,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) { // Email has changed, reset email verification status - user.EmailVerified = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue() + user.EmailVerified = cfg.EmailsVerified.IsTrue() } user.Email = updatedUser.Email @@ -639,7 +639,7 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user return nil } -func (s *UserService) SendEmailVerification(ctx context.Context, userID string) error { +func (s *UserService) SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error { user, err := s.GetUser(ctx, userID) if err != nil { return err @@ -666,7 +666,7 @@ func (s *UserService) SendEmailVerification(ctx context.Context, userID string) return err } - return SendEmail(ctx, s.emailService, email.Address{ + return SendEmail(ctx, s.emailService, dbConfig, email.Address{ Name: user.FullName(), Email: *user.Email, }, EmailVerificationTemplate, &EmailVerificationTemplateData{ diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 3799b36f..3b885d69 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -6,13 +6,13 @@ import ( "github.com/stretchr/testify/require" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/storage" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) -func newTestUserService(t *testing.T, appConfig *AppConfigService) (*UserService, *UserGroupService) { +func newTestUserService(t *testing.T) (*UserService, *UserGroupService) { t.Helper() db := testutils.NewDatabaseForTest(t) @@ -25,22 +25,19 @@ func newTestUserService(t *testing.T, appConfig *AppConfigService) (*UserService nil, nil, nil, - appConfig, NewCustomClaimService(db), NewAppImagesService(map[string]string{}, fileStorage), nil, fileStorage, ) - groupService := NewUserGroupService(db, appConfig, nil) + groupService := NewUserGroupService(db, nil) return userService, groupService } func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) { - appConfig := NewTestAppConfigService(&model.AppConfig{ - RequireUserEmail: model.AppConfigVariable{Value: "false"}, - }) - userService, groupService := newTestUserService(t, appConfig) + config := &appconfig.AppConfigModel{RequireUserEmail: "false"} + userService, groupService := newTestUserService(t) group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{ Name: "members", @@ -52,7 +49,7 @@ func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) { // Create a user that is a member of the group // This mirrors signing up via an invite link that adds the user to a group email := "member@example.com" - _, err = userService.CreateUser(t.Context(), dto.UserCreateDto{ + _, err = userService.CreateUser(t.Context(), config, dto.UserCreateDto{ Username: "member", Email: &email, FirstName: "Group", @@ -70,10 +67,8 @@ func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) { } func TestCreateUserBumpsDefaultGroupUpdatedAt(t *testing.T) { - appConfig := NewTestAppConfigService(&model.AppConfig{ - RequireUserEmail: model.AppConfigVariable{Value: "false"}, - }) - userService, groupService := newTestUserService(t, appConfig) + config := &appconfig.AppConfigModel{RequireUserEmail: "false"} + userService, groupService := newTestUserService(t) group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{ Name: "default", @@ -85,11 +80,11 @@ func TestCreateUserBumpsDefaultGroupUpdatedAt(t *testing.T) { // Configure the group as a default signup group defaultGroups, err := json.Marshal([]string{group.ID}) require.NoError(t, err) - appConfig.dbConfig.Load().SignupDefaultUserGroupIDs.Value = string(defaultGroups) + config.SignupDefaultUserGroupIDs = appconfig.AppConfigValue(defaultGroups) // Create a user without explicit group IDs, so the default groups apply email := "default@example.com" - _, err = userService.CreateUser(t.Context(), dto.UserCreateDto{ + _, err = userService.CreateUser(t.Context(), config, dto.UserCreateDto{ Username: "defaultmember", Email: &email, FirstName: "Default", diff --git a/backend/internal/usersignup/handler.go b/backend/internal/usersignup/handler.go index 5abdbcd7..c8d96ef2 100644 --- a/backend/internal/usersignup/handler.go +++ b/backend/internal/usersignup/handler.go @@ -1,6 +1,7 @@ package usersignup import ( + "fmt" "net/http" "time" @@ -16,10 +17,10 @@ const defaultSignupTokenDuration = time.Hour type handler struct { service *Service - appConfig AppConfigProvider + appConfig AppConfigResolver } -func newHandler(service *Service, appConfig AppConfigProvider) *handler { +func newHandler(service *Service, appConfig AppConfigResolver) *handler { return &handler{service: service, appConfig: appConfig} } @@ -48,13 +49,19 @@ func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) { // @Success 200 {object} dto.UserDto // @Router /api/signup/setup [post] func (h *handler) signUpInitialAdmin(c *gin.Context) { + config, err := h.appConfig.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + var input signUpDto if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } - user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), input) + user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), config, input) if err != nil { _ = c.Error(err) return @@ -66,7 +73,7 @@ func (h *handler) signUpInitialAdmin(c *gin.Context) { return } - maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) @@ -169,6 +176,12 @@ func (h *handler) deleteSignupToken(c *gin.Context) { // @Success 201 {object} dto.UserDto // @Router /api/signup [post] func (h *handler) signup(c *gin.Context) { + config, err := h.appConfig.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + var input signUpDto if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) @@ -178,13 +191,13 @@ func (h *handler) signup(c *gin.Context) { ipAddress := c.ClientIP() userAgent := c.GetHeader("User-Agent") - user, accessToken, err := h.service.SignUp(c.Request.Context(), input, ipAddress, userAgent) + user, accessToken, err := h.service.SignUp(c.Request.Context(), config, input, ipAddress, userAgent) if err != nil { _ = c.Error(err) return } - maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, accessToken) var userDto dto.UserDto diff --git a/backend/internal/usersignup/module.go b/backend/internal/usersignup/module.go index 54f4b0b8..8a7acacf 100644 --- a/backend/internal/usersignup/module.go +++ b/backend/internal/usersignup/module.go @@ -2,28 +2,31 @@ package usersignup import ( "context" + "time" "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" ) type TokenService interface { - GenerateAccessToken(user model.User, authenticationMethod string) (string, error) + GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) } type AuditLogger interface { Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) } -type AppConfigProvider interface { - GetDbConfig() *model.AppConfig +type UserCreator interface { + CreateUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) } -type UserCreator interface { - CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) +// AppConfigResolver loads the current application configuration, so handlers can pass it explicitly to the service methods that need it +type AppConfigResolver interface { + GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error) } type Dependencies struct { @@ -31,8 +34,8 @@ type Dependencies struct { Signer TokenService AuditLog AuditLogger - AppConfig AppConfigProvider UserCreator UserCreator + AppConfig AppConfigResolver } type Module struct { diff --git a/backend/internal/usersignup/service.go b/backend/internal/usersignup/service.go index 0ec3a76d..26b23157 100644 --- a/backend/internal/usersignup/service.go +++ b/backend/internal/usersignup/service.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -25,7 +26,6 @@ type Service struct { userCreator UserCreator signer TokenService auditLog AuditLogger - appConfig AppConfigProvider } func newService(deps Dependencies) *Service { @@ -34,11 +34,10 @@ func newService(deps Dependencies) *Service { userCreator: deps.UserCreator, signer: deps.Signer, auditLog: deps.AuditLog, - appConfig: deps.AppConfig, } } -func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, userAgent string) (model.User, string, error) { +func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel, signupData signUpDto, ipAddress, userAgent string) (model.User, string, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -46,8 +45,7 @@ func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, u tokenProvided := signupData.Token != "" - config := s.appConfig.GetDbConfig() - if config.AllowUserSignups.Value != "open" && !tokenProvided { + if config.AllowUserSignups.String() != "open" && !tokenProvided { return model.User{}, "", &common.OpenSignupDisabledError{} } @@ -84,15 +82,15 @@ func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, u LastName: signupData.LastName, DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName), UserGroupIds: userGroupIDs, - EmailVerified: s.appConfig.GetDbConfig().EmailsVerified.IsTrue(), + EmailVerified: config.EmailsVerified.IsTrue(), } - user, err := s.userCreator.CreateUserInternal(ctx, userToCreate, false, tx) + user, err := s.userCreator.CreateUserInternal(ctx, config, userToCreate, false, tx) if err != nil { return model.User{}, "", err } - accessToken, err := s.signer.GenerateAccessToken(user, "") + accessToken, err := s.signer.GenerateAccessToken(user, "", config.SessionDuration.AsDurationMinutes()) if err != nil { return model.User{}, "", err } @@ -122,7 +120,7 @@ func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, u return user, accessToken, nil } -func (s *Service) SignUpInitialAdmin(ctx context.Context, signUpData signUpDto) (model.User, string, error) { +func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppConfigModel, signUpData signUpDto) (model.User, string, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -145,12 +143,12 @@ func (s *Service) SignUpInitialAdmin(ctx context.Context, signUpData signUpDto) IsAdmin: true, } - user, err := s.userCreator.CreateUserInternal(ctx, userToCreate, false, tx) + user, err := s.userCreator.CreateUserInternal(ctx, config, userToCreate, false, tx) if err != nil { return model.User{}, "", err } - token, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword) + token, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword, config.SessionDuration.AsDurationMinutes()) if err != nil { return model.User{}, "", err } diff --git a/backend/internal/webauthn/handler.go b/backend/internal/webauthn/handler.go index 1cfeb447..77d9e9a9 100644 --- a/backend/internal/webauthn/handler.go +++ b/backend/internal/webauthn/handler.go @@ -1,6 +1,7 @@ package webauthn import ( + "fmt" "net/http" "github.com/gin-gonic/gin" @@ -13,16 +14,22 @@ import ( type handler struct { service *Service - appConfig AppConfigProvider + appConfig AppConfigResolver } -func newHandler(service *Service, appConfig AppConfigProvider) *handler { +func newHandler(service *Service, appConfig AppConfigResolver) *handler { return &handler{service: service, appConfig: appConfig} } func (h *handler) beginRegistration(c *gin.Context) { + dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + userID := c.GetString("userID") - options, err := h.service.BeginRegistration(c.Request.Context(), userID) + options, err := h.service.BeginRegistration(c.Request.Context(), dbConfig, userID) if err != nil { _ = c.Error(err) return @@ -67,6 +74,12 @@ func (h *handler) beginLogin(c *gin.Context) { } func (h *handler) verifyLogin(c *gin.Context) { + dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + sessionID, err := c.Cookie(cookie.SessionIdCookieName) if err != nil { _ = c.Error(&common.MissingSessionIdError{}) @@ -79,7 +92,7 @@ func (h *handler) verifyLogin(c *gin.Context) { return } - user, token, err := h.service.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent()) + user, token, err := h.service.VerifyLogin(c.Request.Context(), dbConfig, sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent()) if err != nil { _ = c.Error(err) return @@ -91,7 +104,7 @@ func (h *handler) verifyLogin(c *gin.Context) { return } - maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) diff --git a/backend/internal/webauthn/module.go b/backend/internal/webauthn/module.go index 41d945e8..47017145 100644 --- a/backend/internal/webauthn/module.go +++ b/backend/internal/webauthn/module.go @@ -8,22 +8,24 @@ import ( "github.com/lestrrat-go/jwx/v3/jwt" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/model" ) type TokenService interface { - GenerateAccessToken(user model.User, authenticationMethod string) (string, error) + GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) VerifyAccessToken(tokenString string) (jwt.Token, error) GetAuthenticationMethod(token jwt.Token) (string, error) } type AuditLogger interface { Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) - CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog + CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, emailLoginNotificationEnabled bool) model.AuditLog } -type AppConfigProvider interface { - GetDbConfig() *model.AppConfig +// AppConfigResolver loads the current application configuration, so handlers can pass it explicitly to the service methods that need it +type AppConfigResolver interface { + GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error) } type Dependencies struct { @@ -32,7 +34,7 @@ type Dependencies struct { Signer TokenService AuditLog AuditLogger - AppConfig AppConfigProvider + AppConfig AppConfigResolver } type Module struct { diff --git a/backend/internal/webauthn/service.go b/backend/internal/webauthn/service.go index 1fc01bcf..0443b9e9 100644 --- a/backend/internal/webauthn/service.go +++ b/backend/internal/webauthn/service.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -23,17 +24,19 @@ import ( // It must match the value emitted by the JWT service in the access token's "amr" claim const authenticationMethodPhishingResistant = "phr" +const defaultRPDisplayName = "Pocket ID" + type Service struct { - db *gorm.DB - webAuthn *gowebauthn.WebAuthn - signer TokenService - auditLog AuditLogger - appConfig AppConfigProvider + db *gorm.DB + webAuthn *gowebauthn.WebAuthn + signer TokenService + auditLog AuditLogger } func newService(deps Dependencies) (*Service, error) { wa, err := gowebauthn.New(&gowebauthn.Config{ - RPDisplayName: deps.AppConfig.GetDbConfig().AppName.Value, + // Set a default value, it will be set again later + RPDisplayName: defaultRPDisplayName, RPID: utils.GetHostnameFromURL(deps.AppURL), RPOrigins: []string{deps.AppURL}, AuthenticatorSelection: protocol.AuthenticatorSelection{ @@ -57,22 +60,21 @@ func newService(deps Dependencies) (*Service, error) { } return &Service{ - db: deps.DB, - webAuthn: wa, - signer: deps.Signer, - auditLog: deps.AuditLog, - appConfig: deps.AppConfig, + db: deps.DB, + webAuthn: wa, + signer: deps.Signer, + auditLog: deps.AuditLog, }, nil } -func (s *Service) BeginRegistration(ctx context.Context, userID string) (*PublicKeyCredentialCreationOptions, error) { +func (s *Service) BeginRegistration(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) (*PublicKeyCredentialCreationOptions, error) { + s.updateWebAuthnConfig(dbConfig) + tx := s.db.Begin() defer func() { tx.Rollback() }() - s.updateWebAuthnConfig() - var user model.User err := tx. WithContext(ctx). @@ -227,7 +229,7 @@ func (s *Service) BeginLogin(ctx context.Context) (*PublicKeyCredentialRequestOp }, nil } -func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) { +func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -270,12 +272,12 @@ func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialA return model.User{}, "", &common.UserDisabledError{} } - token, err := s.signer.GenerateAccessToken(*user, authenticationMethodPhishingResistant) + token, err := s.signer.GenerateAccessToken(*user, authenticationMethodPhishingResistant, dbConfig.SessionDuration.AsDurationMinutes()) if err != nil { return model.User{}, "", err } - s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx) + s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx, dbConfig.EmailLoginNotificationEnabled.IsTrue()) err = tx.Commit().Error if err != nil { @@ -373,8 +375,8 @@ func (s *Service) UpdateCredential(ctx context.Context, userID, credentialID, na } // updateWebAuthnConfig updates the WebAuthn configuration with the app name as it can change during runtime -func (s *Service) updateWebAuthnConfig() { - s.webAuthn.Config.RPDisplayName = s.appConfig.GetDbConfig().AppName.Value +func (s *Service) updateWebAuthnConfig(dbConfig *appconfig.AppConfigModel) { + s.webAuthn.Config.RPDisplayName = dbConfig.AppName.String() } func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) { diff --git a/backend/internal/webauthn/service_test.go b/backend/internal/webauthn/service_test.go index 0551de41..1ccfeece 100644 --- a/backend/internal/webauthn/service_test.go +++ b/backend/internal/webauthn/service_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -28,7 +29,7 @@ func newFakeSigner() *fakeSigner { return &fakeSigner{tokens: map[string]jwt.Token{}} } -func (s *fakeSigner) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) { +func (s *fakeSigner) GenerateAccessToken(user model.User, authenticationMethod string, _ time.Duration) (string, error) { builder := jwt.NewBuilder(). Subject(user.ID). IssuedAt(time.Now()) @@ -85,7 +86,7 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { t.Run("accepts a fresh access token from WebAuthn login", func(t *testing.T) { service, signer, user := setupService(t) - accessToken, err := signer.GenerateAccessToken(user, authenticationMethodPhishingResistant) + accessToken, err := signer.GenerateAccessToken(user, authenticationMethodPhishingResistant, time.Hour) require.NoError(t, err) reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken) @@ -96,7 +97,7 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { t.Run("rejects a fresh access token from one-time access login", func(t *testing.T) { service, signer, user := setupService(t) - accessToken, err := signer.GenerateAccessToken(user, "otp") + accessToken, err := signer.GenerateAccessToken(user, "otp", time.Hour) require.NoError(t, err) reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken) @@ -108,7 +109,7 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) { service, signer, user := setupService(t) - accessToken, err := signer.GenerateAccessToken(user, "") + accessToken, err := signer.GenerateAccessToken(user, "", time.Hour) require.NoError(t, err) reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken) @@ -119,6 +120,18 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { }) } +func TestWebAuthnDisplayNameUsesRequestConfig(t *testing.T) { + service, err := newService(Dependencies{ + DB: testutils.NewDatabaseForTest(t), + AppURL: "https://example.com", + }) + require.NoError(t, err) + require.Equal(t, defaultRPDisplayName, service.webAuthn.Config.RPDisplayName) + + service.updateWebAuthnConfig(&appconfig.AppConfigModel{AppName: "Custom App"}) + require.Equal(t, "Custom App", service.webAuthn.Config.RPDisplayName) +} + func TestConsumeReauthenticationTokenReturnsTokenCreationTime(t *testing.T) { db := testutils.NewDatabaseForTest(t) service := &Service{db: db} diff --git a/backend/resources/migrations/postgres/20260718000000_freeze_config.down.sql b/backend/resources/migrations/postgres/20260718000000_freeze_config.down.sql new file mode 100644 index 00000000..5dbbaac9 --- /dev/null +++ b/backend/resources/migrations/postgres/20260718000000_freeze_config.down.sql @@ -0,0 +1,16 @@ +-- Recreate the standalone config table with the same schema it had before it was frozen +CREATE TABLE app_config_variables +( + key VARCHAR(100) NOT NULL PRIMARY KEY, + value TEXT NOT NULL +); + +-- Populate it from the frozen JSON document stored in the "kv" table +-- json_each expands the JSON object back into one row per key/value pair. +INSERT INTO app_config_variables (key, value) +SELECT je.key, je.value +FROM kv, json_each_text(kv."value"::json) AS je(key, value) +WHERE kv."key" = 'config_migrated'; + +-- Remove the frozen config from the "kv" table +DELETE FROM kv WHERE "key" = 'config_migrated'; diff --git a/backend/resources/migrations/postgres/20260718000000_freeze_config.up.sql b/backend/resources/migrations/postgres/20260718000000_freeze_config.up.sql new file mode 100644 index 00000000..cba856cd --- /dev/null +++ b/backend/resources/migrations/postgres/20260718000000_freeze_config.up.sql @@ -0,0 +1,12 @@ +-- Freeze the app configuration +-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key +-- +-- json_object_agg aggregates all rows into a JSON object +-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty +INSERT INTO kv ("key", "value") +SELECT 'config_migrated', json_object_agg("key", "value")::text +FROM app_config_variables +HAVING count(*) > 0; + +-- Drop the now-frozen standalone config table +DROP TABLE app_config_variables; diff --git a/backend/resources/migrations/sqlite/20260718000000_freeze_config.down.sql b/backend/resources/migrations/sqlite/20260718000000_freeze_config.down.sql new file mode 100644 index 00000000..f807b25f --- /dev/null +++ b/backend/resources/migrations/sqlite/20260718000000_freeze_config.down.sql @@ -0,0 +1,22 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +-- Recreate the standalone config table with the same schema it had before it was frozen +CREATE TABLE app_config_variables +( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL +); + +-- Populate it from the frozen JSON document stored in the "kv" table +-- json_each expands the JSON object back into one row per key/value pair. +INSERT INTO app_config_variables ("key", "value") +SELECT je.key, je.value +FROM kv, json_each(kv."value") AS je +WHERE kv."key" = 'config_migrated'; + +-- Remove the frozen config from the "kv" table +DELETE FROM kv WHERE "key" = 'config_migrated'; + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20260718000000_freeze_config.up.sql b/backend/resources/migrations/sqlite/20260718000000_freeze_config.up.sql new file mode 100644 index 00000000..1b951fd2 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260718000000_freeze_config.up.sql @@ -0,0 +1,18 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +-- Freeze the app configuration +-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key +-- +-- json_group_object aggregates all rows into a JSON object +-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty +INSERT INTO kv ("key", "value") +SELECT 'config_migrated', json_group_object("key", "value") +FROM app_config_variables +HAVING count(*) > 0; + +-- Drop the now-frozen standalone config table +DROP TABLE app_config_variables; + +COMMIT; +PRAGMA foreign_keys=ON; From 0983e911693396d8c807331ed867be5369f501f2 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Tue, 21 Jul 2026 14:59:51 +0200 Subject: [PATCH 6/7] fix: honor webauthn user verification setting --- backend/internal/webauthn/service.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/internal/webauthn/service.go b/backend/internal/webauthn/service.go index 0443b9e9..0e35dc8a 100644 --- a/backend/internal/webauthn/service.go +++ b/backend/internal/webauthn/service.go @@ -140,10 +140,11 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user } session := gowebauthn.SessionData{ - Challenge: storedSession.Challenge, - Expires: storedSession.ExpiresAt.ToTime(), - CredParams: storedSession.CredentialParams, - UserID: []byte(userID), + Challenge: storedSession.Challenge, + Expires: storedSession.ExpiresAt.ToTime(), + CredParams: storedSession.CredentialParams, + UserVerification: protocol.UserVerificationRequirement(storedSession.UserVerification), + UserID: []byte(userID), } var user model.User From 942bb3be2e92bd7b6bf1d89c40cab49544194d43 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Tue, 21 Jul 2026 15:26:41 +0200 Subject: [PATCH 7/7] ci/cd: remove not used permissions --- .github/workflows/backend-linter.yml | 1 - .github/workflows/svelte-check.yml | 1 - .github/workflows/unit-tests.yml | 1 - .github/workflows/update-aaguids.yml | 1 - 4 files changed, 4 deletions(-) diff --git a/.github/workflows/backend-linter.yml b/.github/workflows/backend-linter.yml index 0b300198..4904bd18 100644 --- a/.github/workflows/backend-linter.yml +++ b/.github/workflows/backend-linter.yml @@ -17,7 +17,6 @@ permissions: pull-requests: read # Optional: allow write access to checks to allow the action to annotate code in the PR. checks: write - id-token: write jobs: golangci-lint: diff --git a/.github/workflows/svelte-check.yml b/.github/workflows/svelte-check.yml index 015be719..20caaf37 100644 --- a/.github/workflows/svelte-check.yml +++ b/.github/workflows/svelte-check.yml @@ -25,7 +25,6 @@ permissions: contents: read checks: write pull-requests: write - id-token: write jobs: type-check: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 224b3b8a..bcd99c98 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -11,7 +11,6 @@ on: permissions: contents: read - id-token: write actions: write jobs: diff --git a/.github/workflows/update-aaguids.yml b/.github/workflows/update-aaguids.yml index fbe42e13..871b00d2 100644 --- a/.github/workflows/update-aaguids.yml +++ b/.github/workflows/update-aaguids.yml @@ -8,7 +8,6 @@ on: permissions: contents: write pull-requests: write - id-token: write jobs: update-aaguids: