mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-31 06:31:58 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
716981aa04 | ||
|
|
66e3284898 | ||
|
|
695f0ff005 | ||
|
|
53f2cf36fc | ||
|
|
8ffcf3bdc6 | ||
|
|
a07af96f84 | ||
|
|
b1daa65228 | ||
|
|
dd1c42264e | ||
|
|
384fbbbe10 | ||
|
|
1a54010950 | ||
|
|
9bf22791a9 | ||
|
|
5d82f22b12 | ||
|
|
6979c3bb32 | ||
|
|
b9f14e55e2 | ||
|
|
2582988f94 | ||
|
|
4ea62a9f3d | ||
|
|
1f60b2cf39 | ||
|
|
a02865a1aa | ||
|
|
ad549dad9b | ||
|
|
20103220d9 | ||
|
|
fe2c825244 | ||
|
|
4b858a0251 |
No files matched your search
+4
-1
@@ -21,6 +21,9 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
BETTER_AUTH_URL=http://localhost:4321
|
||||
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
|
||||
# Better Auth log verbosity: debug | info | warn | error (default: warn).
|
||||
# Set to "debug" to trace SSO/OIDC sign-in and callback issues.
|
||||
# BETTER_AUTH_LOG_LEVEL=debug
|
||||
|
||||
# ===========================================
|
||||
# REVERSE PROXY CONFIGURATION
|
||||
@@ -60,7 +63,7 @@ PUBLIC_BETTER_AUTH_URL=http://localhost:4321
|
||||
|
||||
# Docker Registry Configuration
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_IMAGE=raylabshq/gitea-mirror:
|
||||
DOCKER_IMAGE=raylabshq/gitea-mirror
|
||||
DOCKER_TAG=latest
|
||||
|
||||
# ===========================================
|
||||
|
||||
@@ -51,13 +51,13 @@ jobs:
|
||||
ref: ${{ env.SHA }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log into registry
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# Login to Docker Hub for Docker Scout (optional - provides better vulnerability data)
|
||||
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this
|
||||
- name: Log into Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
# Extract metadata for Docker
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
|
||||
labels: |
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
# Build and push Docker image
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
# Load image locally for security scanning (PRs only)
|
||||
- name: Load image for scanning
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
|
||||
# Docker Scout comprehensive security analysis
|
||||
- name: Docker Scout - Vulnerability Analysis & Recommendations
|
||||
uses: docker/scout-action@v1
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
command: cves,recommendations
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
|
||||
# Docker Scout for Pull Requests (using local image)
|
||||
- name: Docker Scout - Vulnerability Analysis (PR)
|
||||
uses: docker/scout-action@v1
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: cves,recommendations
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
|
||||
# Compare to latest for PRs and pushes
|
||||
- name: Docker Scout - Compare to Latest
|
||||
uses: docker/scout-action@v1
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: compare
|
||||
|
||||
@@ -88,6 +88,24 @@ jobs:
|
||||
|
||||
- name: Start Gitea and git-server containers
|
||||
run: |
|
||||
# Retry pulls before `up` — Docker Hub occasionally returns
|
||||
# "context deadline exceeded" on the first attempt. Pulling
|
||||
# separately also keeps the failure mode obvious if it persists.
|
||||
echo "Pulling images..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker compose -f tests/e2e/docker-compose.e2e.yml pull --quiet; then
|
||||
echo "✓ Images pulled (attempt $attempt)"
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "ERROR: docker compose pull failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_s=$((attempt * 10))
|
||||
echo "Pull attempt $attempt failed, retrying in ${sleep_s}s..."
|
||||
sleep "$sleep_s"
|
||||
done
|
||||
|
||||
echo "Starting containers via docker compose..."
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml up -d
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
|
||||
- name: Setup Nix Cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
|
||||
|
||||
- name: Regenerate bun.nix from bun.lock
|
||||
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
|
||||
|
||||
@@ -52,3 +52,4 @@ tests/e2e/git-repos/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
.playwright-mcp/
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.3.13-debian AS base
|
||||
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS base
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
|
||||
@@ -49,7 +49,7 @@ RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lf
|
||||
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
|
||||
|
||||
# ----------------------------
|
||||
FROM oven/bun:1.3.13-debian AS runner
|
||||
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS runner
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
git wget sqlite3 openssl ca-certificates \
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"@astrojs/mdx": "5.0.0",
|
||||
"@astrojs/node": "10.0.1",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@better-auth/sso": "1.5.5",
|
||||
"@better-auth/oauth-provider": "1.6.11",
|
||||
"@better-auth/sso": "1.6.11",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@@ -38,7 +39,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.5.5",
|
||||
"better-auth": "1.6.11",
|
||||
"buffer": "^6.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -82,16 +83,13 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"defu": "^6.1.7",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"kysely": "^0.28.16",
|
||||
"@xmldom/xmldom": "^0.8.13",
|
||||
"devalue": "^5.8.1",
|
||||
"fast-uri": "^3.1.2",
|
||||
"fast-xml-builder": "^1.1.7",
|
||||
"kysely": "^0.28.17",
|
||||
"lodash": "^4.18.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"picomatch": "^4.0.4",
|
||||
"rollup": ">=4.59.0",
|
||||
"svgo": "^4.0.1",
|
||||
},
|
||||
"packages": {
|
||||
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
|
||||
@@ -168,23 +166,25 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
|
||||
"@better-auth/core": ["@better-auth/core@1.6.11", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ=="],
|
||||
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw=="],
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw=="],
|
||||
|
||||
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-LmHffIVnqbfsxcxckMOoE8MwibWrbVFch+kwPKJ5OFDFv6lin75ufN7ZZ7twH0IMPLT/FcgzaRjP8jRrXRef9g=="],
|
||||
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "kysely": "^0.28.17" }, "optionalPeers": ["kysely"] }, "sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg=="],
|
||||
|
||||
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0" } }, "sha512-4X0j1/2L+nsgmObjmy9xEGUFWUv38Qjthp558fwS3DAp6ueWWyCaxaD6VJZ7m5qPNMrsBStO5WGP8CmJTEWm7g=="],
|
||||
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0" } }, "sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q=="],
|
||||
|
||||
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "mongodb": "^6.0.0 || ^7.0.0" } }, "sha512-P1J9ljL5X5k740I8Rx1esPWNgWYPdJR5hf2CY7BwDSrQFPUHuzeCg0YhtEEP55niNateTXhBqGAcy0fVOeamZg=="],
|
||||
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA=="],
|
||||
|
||||
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg=="],
|
||||
"@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.6.11", "", { "dependencies": { "jose": "^6.1.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.11", "better-call": "1.3.5" } }, "sha512-iMywpOEAiAUdtvpaRS8yKye+wO3AieOB3Sfv8czkmPduzFuKBICCWuOEAElQEk5tQz3vzWx64zNlLBkgEAOhuw=="],
|
||||
|
||||
"@better-auth/sso": ["@better-auth/sso@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "fast-xml-parser": "^5.4.1", "jose": "^6.1.3", "samlify": "^2.10.2", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "1.5.5", "better-auth": "1.5.5", "better-call": "1.3.2" } }, "sha512-G3tvv5oKtEfpmBrt7Db/hSl5A3xttUkB4EhEjb202UhHz/XBiT0Orv5CkRa0kmjRyyAwOzn/lKZzYsd3VrjViA=="],
|
||||
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw=="],
|
||||
|
||||
"@better-auth/telemetry": ["@better-auth/telemetry@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.5" } }, "sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ=="],
|
||||
"@better-auth/sso": ["@better-auth/sso@1.6.11", "", { "dependencies": { "fast-xml-parser": "^5.5.7", "jose": "^6.1.3", "samlify": "~2.10.2", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.11", "better-call": "1.3.5" } }, "sha512-lJHmoCayp9Woh/MPKTHDfGq7k1oQbU2yz5tIOZXl/pzrgLxV7fMGo9aJCyabHkw3GHMjBes4byC6aakHYzpZIg=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
|
||||
"@better-auth/telemetry": ["@better-auth/telemetry@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="],
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
|
||||
|
||||
@@ -356,14 +356,14 @@
|
||||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
|
||||
|
||||
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
||||
|
||||
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
|
||||
|
||||
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
|
||||
@@ -390,6 +390,8 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
|
||||
@@ -686,10 +688,6 @@
|
||||
|
||||
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
|
||||
|
||||
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
|
||||
|
||||
"@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
@@ -726,7 +724,7 @@
|
||||
|
||||
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
@@ -772,9 +770,9 @@
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="],
|
||||
"better-auth": ["better-auth@1.6.11", "", { "dependencies": { "@better-auth/core": "1.6.11", "@better-auth/drizzle-adapter": "1.6.11", "@better-auth/kysely-adapter": "1.6.11", "@better-auth/memory-adapter": "1.6.11", "@better-auth/mongo-adapter": "1.6.11", "@better-auth/prisma-adapter": "1.6.11", "@better-auth/telemetry": "1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ=="],
|
||||
|
||||
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
|
||||
"better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
@@ -784,15 +782,13 @@
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"camelcase": ["camelcase@9.0.0", "", {}, "sha512-TO9xmyXTZ9HUHI8M1OnvExxYB0eYVS/1e5s7IDMTAoIcwUd+aNcFODs6Xk83mobk0velyHFQgA1yIrvYc6wclw=="],
|
||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
|
||||
|
||||
@@ -876,7 +872,7 @@
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
|
||||
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
@@ -960,11 +956,11 @@
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.5.6", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw=="],
|
||||
"fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
@@ -1090,7 +1086,7 @@
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
|
||||
"kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
@@ -1184,8 +1180,6 @@
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
@@ -1262,10 +1256,6 @@
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"mongodb": ["mongodb@7.1.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.1.1", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg=="],
|
||||
|
||||
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -1284,6 +1274,8 @@
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
|
||||
|
||||
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
@@ -1314,6 +1306,8 @@
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
|
||||
@@ -1322,7 +1316,7 @@
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.1.3", "", {}, "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ=="],
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
@@ -1434,7 +1428,7 @@
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"samlify": ["samlify@2.11.0", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "camelcase": "^9.0.0", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-1C9ukjlf0rRsuyqdzztqikdItqa33j9NCCDZgeBiWk0etU6vxNB+SWJKW4Flk07ZlhXeev/twALEKrPhIAyfDg=="],
|
||||
"samlify": ["samlify@2.10.2", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.6", "camelcase": "^6.2.0", "node-forge": "^1.3.0", "node-rsa": "^1.1.1", "pako": "^1.0.10", "uuid": "^8.3.2", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.32" } }, "sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA=="],
|
||||
|
||||
"sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
|
||||
|
||||
@@ -1470,8 +1464,6 @@
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
@@ -1486,7 +1478,7 @@
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
@@ -1662,9 +1654,11 @@
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="],
|
||||
"xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
|
||||
|
||||
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
|
||||
|
||||
@@ -1692,8 +1686,6 @@
|
||||
|
||||
"@astrojs/react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
@@ -1764,8 +1756,6 @@
|
||||
|
||||
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"mongodb-connection-string-url/whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
@@ -1774,6 +1764,8 @@
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="],
|
||||
|
||||
"samlify/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"tough-cookie/tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
@@ -1926,10 +1918,6 @@
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
|
||||
"mongodb-connection-string-url/whatwg-url/tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||
|
||||
"mongodb-connection-string-url/whatwg-url/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
"tough-cookie/tldts/tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
||||
|
||||
@@ -1409,9 +1409,9 @@
|
||||
url = "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz";
|
||||
hash = "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==";
|
||||
};
|
||||
"@xmldom/xmldom@0.8.11" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz";
|
||||
hash = "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==";
|
||||
"@xmldom/xmldom@0.8.13" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz";
|
||||
hash = "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==";
|
||||
};
|
||||
"acorn-jsx@5.3.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz";
|
||||
@@ -1701,9 +1701,9 @@
|
||||
url = "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz";
|
||||
hash = "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==";
|
||||
};
|
||||
"defu@6.1.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz";
|
||||
hash = "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==";
|
||||
"defu@6.1.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz";
|
||||
hash = "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==";
|
||||
};
|
||||
"depd@2.0.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz";
|
||||
@@ -1725,9 +1725,9 @@
|
||||
url = "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz";
|
||||
hash = "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==";
|
||||
};
|
||||
"devalue@5.6.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz";
|
||||
hash = "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==";
|
||||
"devalue@5.8.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz";
|
||||
hash = "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==";
|
||||
};
|
||||
"devlop@1.1.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz";
|
||||
@@ -1773,9 +1773,9 @@
|
||||
url = "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz";
|
||||
hash = "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==";
|
||||
};
|
||||
"drizzle-orm@0.45.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz";
|
||||
hash = "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==";
|
||||
"drizzle-orm@0.45.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz";
|
||||
hash = "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==";
|
||||
};
|
||||
"dset@3.1.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz";
|
||||
@@ -1909,17 +1909,17 @@
|
||||
url = "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz";
|
||||
hash = "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==";
|
||||
};
|
||||
"fast-uri@3.1.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz";
|
||||
hash = "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==";
|
||||
"fast-uri@3.1.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz";
|
||||
hash = "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==";
|
||||
};
|
||||
"fast-xml-builder@1.1.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz";
|
||||
hash = "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==";
|
||||
"fast-xml-builder@1.2.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz";
|
||||
hash = "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==";
|
||||
};
|
||||
"fast-xml-parser@5.5.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz";
|
||||
hash = "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==";
|
||||
"fast-xml-parser@5.5.6" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz";
|
||||
hash = "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==";
|
||||
};
|
||||
"fdir@6.5.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz";
|
||||
@@ -2177,9 +2177,9 @@
|
||||
url = "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz";
|
||||
hash = "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==";
|
||||
};
|
||||
"kysely@0.28.12" = fetchurl {
|
||||
url = "https://registry.npmjs.org/kysely/-/kysely-0.28.12.tgz";
|
||||
hash = "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw==";
|
||||
"kysely@0.28.17" = fetchurl {
|
||||
url = "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz";
|
||||
hash = "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==";
|
||||
};
|
||||
"lightningcss-android-arm64@1.31.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz";
|
||||
@@ -2305,9 +2305,9 @@
|
||||
url = "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz";
|
||||
hash = "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==";
|
||||
};
|
||||
"lodash@4.17.21" = fetchurl {
|
||||
url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
|
||||
hash = "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==";
|
||||
"lodash@4.18.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz";
|
||||
hash = "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==";
|
||||
};
|
||||
"longest-streak@3.1.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz";
|
||||
@@ -2709,6 +2709,10 @@
|
||||
url = "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz";
|
||||
hash = "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==";
|
||||
};
|
||||
"path-expression-matcher@1.5.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz";
|
||||
hash = "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==";
|
||||
};
|
||||
"pathe@2.0.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz";
|
||||
hash = "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==";
|
||||
@@ -2721,13 +2725,9 @@
|
||||
url = "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz";
|
||||
hash = "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==";
|
||||
};
|
||||
"picomatch@2.3.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz";
|
||||
hash = "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==";
|
||||
};
|
||||
"picomatch@4.0.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz";
|
||||
hash = "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==";
|
||||
"picomatch@4.0.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz";
|
||||
hash = "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==";
|
||||
};
|
||||
"playwright-core@1.58.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz";
|
||||
@@ -3421,6 +3421,10 @@
|
||||
url = "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz";
|
||||
hash = "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==";
|
||||
};
|
||||
"xml-naming@0.1.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz";
|
||||
hash = "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==";
|
||||
};
|
||||
"xml@1.0.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz";
|
||||
hash = "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==";
|
||||
@@ -3489,4 +3493,4 @@
|
||||
url = "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz";
|
||||
hash = "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ Essential application settings required for running Gitea Mirror.
|
||||
| `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth origin for multi-origin access (same rule: origin only, no path). Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
|
||||
| `BETTER_AUTH_LOG_LEVEL` | Better Auth logger verbosity. Set to `debug` to surface the full SSO/OIDC sign-in and callback trace when troubleshooting authentication. Accepted values: `debug`, `info`, `warn`, `error`. (Better Auth does **not** use the `DEBUG` env var.) | `warn` | No |
|
||||
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
|
||||
|
||||
## HTTPS / TLS
|
||||
|
||||
+95
-6
@@ -4,15 +4,16 @@ This guide explains how to configure Single Sign-On (SSO) and OpenID Connect (OI
|
||||
|
||||
## Overview
|
||||
|
||||
Gitea Mirror supports three authentication methods:
|
||||
Gitea Mirror supports four authentication methods:
|
||||
|
||||
1. **Email & Password** - Traditional authentication (always enabled)
|
||||
2. **SSO (Single Sign-On)** - Allow users to authenticate using external OIDC providers
|
||||
3. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
|
||||
3. **Header / Forward Authentication** - Trust pre-authenticated requests from a reverse proxy (Authentik, Authelia, oauth2-proxy, etc.)
|
||||
4. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
|
||||
|
||||
## Configuration
|
||||
|
||||
All SSO and OIDC settings are managed through the web UI in the Configuration page under the "Authentication" tab.
|
||||
SSO and OIDC Provider settings are managed through the web UI in the Configuration page under the "Authentication" tab. Header / Forward Authentication is configured via environment variables only — see [Setting up Header / Forward Authentication](#setting-up-header--forward-authentication) below.
|
||||
|
||||
## Setting up SSO (Single Sign-On)
|
||||
|
||||
@@ -27,7 +28,7 @@ SSO allows your users to sign in using external identity providers like Google,
|
||||
#### Required Fields
|
||||
|
||||
- **Issuer URL**: The OIDC issuer URL (e.g., `https://accounts.google.com`)
|
||||
- **Domain**: The email domain for this provider (e.g., `example.com`)
|
||||
- **Domain**: The email domain this provider serves (e.g., `example.com`). **This is load-bearing for account-linking trust — see [Account Linking](#account-linking) below.** Multi-domain IdPs can use a comma-separated list (`example.com,subsidiary.com`).
|
||||
- **Provider ID**: A unique identifier for this provider (e.g., `google-sso`)
|
||||
- **Client ID**: The OAuth client ID from your provider
|
||||
- **Client Secret**: The OAuth client secret from your provider
|
||||
@@ -56,6 +57,22 @@ https://your-domain.com/api/auth/sso/callback/{provider-id}
|
||||
|
||||
Replace `{provider-id}` with your chosen Provider ID.
|
||||
|
||||
### Account Linking
|
||||
|
||||
When a user signs in via SSO whose email matches an **existing** email/password account, Gitea Mirror will try to *link* the SSO identity to that account so the user lands on the same dashboard — instead of being bounced to the login page or creating a duplicate account.
|
||||
|
||||
The trust model is **domain-scoped**:
|
||||
|
||||
- Every SSO provider you register is automatically marked `domainVerified: true` for the **Domain** field you set on registration.
|
||||
- An SSO sign-in is auto-linked to an existing local account **only when** the user's email address actually belongs to that registered domain. Cross-domain emails are rejected even if the provider is registered.
|
||||
- Local email/password accounts in this app are never verified (no email-verification flow exists), so we deliberately turn off `requireLocalEmailVerified` on the linker — otherwise no one could ever link.
|
||||
|
||||
**What this means for you:**
|
||||
|
||||
- Set the **Domain** field to the email domain(s) your IdP actually issues identities for. Don't set it to `example.com` if your IdP issues `@elsewhere.com` emails — auto-linking will silently refuse them.
|
||||
- If your IdP allows users to self-register or claim arbitrary emails *within the registered domain*, an attacker on that IdP can absorb a local account by claiming the same email. This is a trust decision: by registering an IdP, you're vouching for its identity model for that domain.
|
||||
- Multi-domain IdPs (one Authentik serving `acme.com,acquired.io`) work fine — list every domain comma-separated in the **Domain** field.
|
||||
|
||||
### Example: Google SSO Setup
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
@@ -98,9 +115,80 @@ Working Authentik deployments (see [#134](https://github.com/RayLabsHQ/gitea-mir
|
||||
|
||||
Notes:
|
||||
- Make sure `BETTER_AUTH_URL` and (if you serve the UI from multiple origins) `BETTER_AUTH_TRUSTED_ORIGINS` point at the public URL users reach. A mismatch can surface as 500 errors after redirect.
|
||||
- Authentik must report the user’s email as verified (default behavior) so Gitea Mirror can auto-link accounts.
|
||||
- Set the **Domain** field to the email domain your Authentik users actually have. Auto-linking to an existing local admin only happens for emails in that domain — see [Account Linking](#account-linking) for the trust model. (Authentik's default email scope mapping returns `email_verified: False` for OIDC clients, which is why account linking is gated on the domain match here rather than the IdP's verified-email claim.)
|
||||
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
|
||||
|
||||
## Setting up Header / Forward Authentication
|
||||
|
||||
Header authentication trusts a reverse proxy (Authentik, Authelia, oauth2-proxy, Traefik forward-auth, etc.) to authenticate users upstream and pass identity in HTTP headers. When enabled, Gitea Mirror reads the configured headers on each request and resolves the user automatically — no login form, no callback.
|
||||
|
||||
> **Important — operator-controlled by design.** Header auth is configured via environment variables only, not the UI. This is intentional: trusting a header means trusting whatever upstream sets it, and that decision belongs to the operator who controls the reverse proxy, not to a logged-in user inside the app. Make sure your reverse proxy strips these headers from inbound client requests, otherwise anyone can spoof them.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HEADER_AUTH_ENABLED` | Master switch — must be `true` to enable | `false` |
|
||||
| `HEADER_AUTH_USER_HEADER` | Header containing the username | `X-Authentik-Username` |
|
||||
| `HEADER_AUTH_EMAIL_HEADER` | Header containing the email address | `X-Authentik-Email` |
|
||||
| `HEADER_AUTH_NAME_HEADER` | Header containing the display name | `X-Authentik-Name` |
|
||||
| `HEADER_AUTH_AUTO_PROVISION` | If `true`, create a new user when an unknown username arrives. If `false`, unknown users are rejected. | `false` |
|
||||
| `HEADER_AUTH_ALLOWED_DOMAINS` | Comma-separated email domain allowlist. Empty = allow any. | _(empty)_ |
|
||||
|
||||
See also: [`docs/ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md#header-authentication-reverse-proxy-sso).
|
||||
|
||||
### Example: Docker Compose with Authentik
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
environment:
|
||||
HEADER_AUTH_ENABLED: "true"
|
||||
HEADER_AUTH_USER_HEADER: "X-Authentik-Username"
|
||||
HEADER_AUTH_EMAIL_HEADER: "X-Authentik-Email"
|
||||
HEADER_AUTH_NAME_HEADER: "X-Authentik-Name"
|
||||
HEADER_AUTH_AUTO_PROVISION: "true"
|
||||
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,example.org"
|
||||
```
|
||||
|
||||
The defaults are Authentik-shaped, so for an Authentik proxy provider you generally only need to set `HEADER_AUTH_ENABLED=true` (and `HEADER_AUTH_AUTO_PROVISION=true` if you want first-login self-registration).
|
||||
|
||||
### Example: Authelia
|
||||
|
||||
Authelia uses different header names — override them:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
HEADER_AUTH_ENABLED: "true"
|
||||
HEADER_AUTH_USER_HEADER: "Remote-User"
|
||||
HEADER_AUTH_EMAIL_HEADER: "Remote-Email"
|
||||
HEADER_AUTH_NAME_HEADER: "Remote-Name"
|
||||
HEADER_AUTH_AUTO_PROVISION: "true"
|
||||
```
|
||||
|
||||
Then configure Authelia's `authz` rules to protect the gitea-mirror route and inject the `Remote-*` headers.
|
||||
|
||||
### Behaviour and lookup order
|
||||
|
||||
- The middleware checks for a cookie session **first**. Header auth only fires when there is no existing session, so users who logged in via password or SSO are not affected.
|
||||
- Lookup is by `HEADER_AUTH_USER_HEADER` value matched against `users.username`; if no match and `HEADER_AUTH_EMAIL_HEADER` is set, a second lookup is tried against `users.email`.
|
||||
- If neither matches:
|
||||
- With `HEADER_AUTH_AUTO_PROVISION=true`, a new user row is created (email defaults to `<username>@header-auth.local` if no email header is present).
|
||||
- With `HEADER_AUTH_AUTO_PROVISION=false`, the request is rejected with a warning logged.
|
||||
- If `HEADER_AUTH_ALLOWED_DOMAINS` is non-empty and the email header arrives, the email's domain must be in the list or the request is rejected.
|
||||
|
||||
### Verifying it's enabled
|
||||
|
||||
When header auth is active, the Authentication settings page renders a green "Header Authentication / Via reverse proxy" badge under the auth-methods status block. You can also probe `/api/auth/header-status`, which returns `{ "enabled": true, ... }` once the env vars are set.
|
||||
|
||||
### Security checklist
|
||||
|
||||
1. **Strip the headers at your edge.** Your reverse proxy must remove inbound `HEADER_AUTH_USER_HEADER` / email / name headers from client requests before re-injecting its own. Otherwise any unauthenticated client can set `X-Authentik-Username: admin` and walk in.
|
||||
2. **Bind the app to the proxy only.** Don't expose Gitea Mirror's port directly to the network when header auth is on; only the trusted proxy should reach it.
|
||||
3. **Use HTTPS between proxy and clients.** Headers travel in plaintext on the link they cross.
|
||||
4. **Be conservative with `HEADER_AUTH_AUTO_PROVISION`.** With it off, you provision users in the UI/DB once and the proxy fills in sessions thereafter — safer for shared deployments.
|
||||
|
||||
## Setting up OIDC Provider
|
||||
|
||||
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
|
||||
@@ -185,7 +273,8 @@ When an application requests authentication:
|
||||
1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI
|
||||
2. **"Provider not found" error**: Ensure the provider is properly configured and enabled
|
||||
3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
|
||||
4. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
|
||||
4. **`?error=UNKNOWN` on the homepage after a successful upstream login** (or `?error=account%20not%20linked` in development): the SSO callback succeeded but Better Auth refused to link the SSO identity to an existing local account. The most common cause is the SSO provider's registered **Domain** not matching the user's actual email domain — see [Account Linking](#account-linking). Set `BETTER_AUTH_LOG_LEVEL=debug` to see Better Auth's full callback trace (look for "User already exist but account isn't linked to ...") and confirm the diagnosis. The same symptom in production gets sanitized to `UNKNOWN` by Better Auth's error page before the redirect, which is why the visible error is opaque.
|
||||
5. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
|
||||
|
||||
### OIDC Provider Issues
|
||||
|
||||
|
||||
+50
-2
@@ -125,11 +125,59 @@ npm start
|
||||
|
||||
### Debug Mode:
|
||||
|
||||
Enable debug logging by setting environment variable:
|
||||
> **Note:** Better Auth uses its own logger and does **not** read the `DEBUG`
|
||||
> environment variable (it is not based on the `debug` npm package). An older
|
||||
> version of this guide suggested `DEBUG=better-auth:*` — that has no effect.
|
||||
|
||||
Better Auth's logger defaults to the `warn` level, so SSO/OIDC sign-in and
|
||||
callback details are hidden. Set the log level to `debug` to surface the full
|
||||
trace:
|
||||
|
||||
```bash
|
||||
DEBUG=better-auth:* bun run dev
|
||||
# Local dev
|
||||
BETTER_AUTH_LOG_LEVEL=debug bun run dev
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Docker Compose
|
||||
services:
|
||||
gitea-mirror:
|
||||
environment:
|
||||
- BETTER_AUTH_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Then watch the server logs (e.g. `docker compose logs -f gitea-mirror`) while you
|
||||
attempt an SSO login. Lines are prefixed with `[Better Auth]:`. Accepted values
|
||||
are `debug`, `info`, `warn`, and `error`.
|
||||
|
||||
#### Debugging a login that bounces back to `/login`
|
||||
|
||||
If clicking the SSO button sends you to the provider and then straight back to
|
||||
the login screen, the OAuth flow itself usually succeeded but **no session
|
||||
cookie was persisted**. Work through these checks:
|
||||
|
||||
1. **Enable `BETTER_AUTH_LOG_LEVEL=debug`** (above) and look for errors during
|
||||
the `/api/auth/sso/callback/<provider-id>` request.
|
||||
2. **Check for `?error=UNKNOWN` on the landing URL** (or `?error=account%20not%20linked` in dev).
|
||||
That's Better Auth's account-linking step refusing to attach the SSO identity
|
||||
to an existing email/password account. The debug log line to look for is
|
||||
`User already exist but account isn't linked to <providerId>`. The fix is
|
||||
almost always to set the SSO provider's **Domain** field to the email domain
|
||||
your users actually have — auto-linking is gated on that domain match.
|
||||
See [docs/SSO-OIDC-SETUP.md#account-linking](./SSO-OIDC-SETUP.md#account-linking).
|
||||
3. **Check the redirect URI** registered in your IdP exactly matches
|
||||
`https://<your-domain>/api/auth/sso/callback/<provider-id>` (scheme, host,
|
||||
and provider ID — no trailing slash).
|
||||
4. **Confirm the session cookie is set.** In the browser DevTools → Network,
|
||||
inspect the callback response for a `Set-Cookie: better-auth-session=…`
|
||||
header, and DevTools → Application → Cookies for the stored cookie. Behind a
|
||||
reverse proxy, ensure `BETTER_AUTH_URL` is your **external HTTPS** URL so the
|
||||
cookie is issued with the correct domain and `Secure` flag, and that the
|
||||
proxy forwards `X-Forwarded-Proto: https` and `X-Forwarded-Host`.
|
||||
5. **A `401` on `/api/sso/applications` is unrelated** to client login — that
|
||||
endpoint backs the OAuth *provider* (consent) management UI and requires an
|
||||
existing session. It is not part of the Authentik/OIDC sign-in flow.
|
||||
|
||||
## Testing Different Scenarios
|
||||
|
||||
### 1. New User Registration
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
-- Migrate the OAuth/OIDC *provider* feature from the deprecated
|
||||
-- `oidc-provider` plugin to `@better-auth/oauth-provider`.
|
||||
--
|
||||
-- Tables: oauth_applications -> oauth_clients, oauth_access_tokens reshaped,
|
||||
-- new oauth_refresh_tokens, oauth_consent -> oauth_consents, plus a `jwks`
|
||||
-- table for the `jwt` plugin (id_token signing keys).
|
||||
--
|
||||
-- Data preservation:
|
||||
-- * Registered clients are copied from oauth_applications into oauth_clients,
|
||||
-- converting the legacy comma-separated `redirect_urls` into the JSON
|
||||
-- string[] (`redirect_uris`) the new adapter expects.
|
||||
-- * Access tokens and consent records are NOT migrated: access tokens are
|
||||
-- short-lived (and the column shape changed entirely), and consents are
|
||||
-- cheaply re-granted on next authorization. Both old tables are dropped.
|
||||
--
|
||||
-- NOTE: legacy client secrets were stored in plaintext, whereas the new
|
||||
-- provider stores them hashed. Migrated client secrets will therefore not
|
||||
-- validate as-is — affected applications must rotate their secret after
|
||||
-- upgrade.
|
||||
|
||||
CREATE TABLE `oauth_clients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`client_secret` text,
|
||||
`name` text,
|
||||
`disabled` integer DEFAULT false,
|
||||
`skip_consent` integer,
|
||||
`enable_end_session` integer,
|
||||
`subject_type` text,
|
||||
`scopes` text,
|
||||
`user_id` text,
|
||||
`uri` text,
|
||||
`icon` text,
|
||||
`contacts` text,
|
||||
`tos` text,
|
||||
`policy` text,
|
||||
`software_id` text,
|
||||
`software_version` text,
|
||||
`software_statement` text,
|
||||
`redirect_uris` text NOT NULL,
|
||||
`post_logout_redirect_uris` text,
|
||||
`token_endpoint_auth_method` text,
|
||||
`grant_types` text,
|
||||
`response_types` text,
|
||||
`public` integer,
|
||||
`type` text,
|
||||
`require_pkce` integer,
|
||||
`reference_id` text,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`updated_at` integer DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_clients_client_id_unique` ON `oauth_clients` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_clients_client_id` ON `oauth_clients` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_clients_user_id` ON `oauth_clients` (`user_id`);--> statement-breakpoint
|
||||
INSERT INTO `oauth_clients` (
|
||||
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
|
||||
`redirect_uris`, `type`, `metadata`, `created_at`, `updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
|
||||
'["' || replace(`redirect_urls`, ',', '","') || '"]',
|
||||
`type`, `metadata`, `created_at`, `updated_at`
|
||||
FROM `oauth_applications`;--> statement-breakpoint
|
||||
DROP TABLE `oauth_applications`;--> statement-breakpoint
|
||||
DROP TABLE `oauth_access_tokens`;--> statement-breakpoint
|
||||
CREATE TABLE `oauth_access_tokens` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text,
|
||||
`client_id` text NOT NULL,
|
||||
`session_id` text,
|
||||
`user_id` text,
|
||||
`reference_id` text,
|
||||
`refresh_id` text,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`scopes` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_access_tokens_token_unique` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_token` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_client_id` ON `oauth_access_tokens` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_user_id` ON `oauth_access_tokens` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `oauth_refresh_tokens` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`session_id` text,
|
||||
`user_id` text NOT NULL,
|
||||
`reference_id` text,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`revoked` integer,
|
||||
`auth_time` integer,
|
||||
`scopes` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_refresh_tokens_token_unique` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_token` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_client_id` ON `oauth_refresh_tokens` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_user_id` ON `oauth_refresh_tokens` (`user_id`);--> statement-breakpoint
|
||||
DROP TABLE `oauth_consent`;--> statement-breakpoint
|
||||
CREATE TABLE `oauth_consents` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`user_id` text,
|
||||
`reference_id` text,
|
||||
`scopes` text NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`updated_at` integer DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_consents_client_id` ON `oauth_consents` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_consents_user_id` ON `oauth_consents` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `jwks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`public_key` text NOT NULL,
|
||||
`private_key` text NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`expires_at` integer
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_organizations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`config_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`normalized_name` text NOT NULL,
|
||||
`avatar_url` text NOT NULL,
|
||||
`membership_role` text DEFAULT 'member' NOT NULL,
|
||||
`is_included` integer DEFAULT true NOT NULL,
|
||||
`destination_org` text,
|
||||
`status` text DEFAULT 'imported' NOT NULL,
|
||||
`last_mirrored` integer,
|
||||
`error_message` text,
|
||||
`repository_count` integer DEFAULT 0 NOT NULL,
|
||||
`public_repository_count` integer,
|
||||
`private_repository_count` integer,
|
||||
`fork_repository_count` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_organizations`("id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at") SELECT "id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at" FROM `organizations`;--> statement-breakpoint
|
||||
DROP TABLE `organizations`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_organizations` RENAME TO `organizations`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_user_id` ON `organizations` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_config_id` ON `organizations` (`config_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_status` ON `organizations` (`status`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_is_included` ON `organizations` (`is_included`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_organizations_user_normalized_name` ON `organizations` (`user_id`,`normalized_name`);--> statement-breakpoint
|
||||
ALTER TABLE `sso_providers` ADD `saml_config` text;--> statement-breakpoint
|
||||
ALTER TABLE `sso_providers` ADD `domain_verified` integer DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,20 @@
|
||||
"when": 1774058400000,
|
||||
"tag": "0011_notification_config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1774062000000,
|
||||
"tag": "0012_oauth_provider_migration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1780377747526,
|
||||
"tag": "0013_slim_galactus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+10
-12
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.15.10",
|
||||
"version": "3.17.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -45,23 +45,21 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"defu": "^6.1.7",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"kysely": "^0.28.16",
|
||||
"@xmldom/xmldom": "^0.8.13",
|
||||
"devalue": "^5.8.1",
|
||||
"fast-uri": "^3.1.2",
|
||||
"fast-xml-builder": "^1.1.7",
|
||||
"kysely": "^0.28.17",
|
||||
"lodash": "^4.18.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"picomatch": "^4.0.4",
|
||||
"rollup": ">=4.59.0",
|
||||
"svgo": "^4.0.1"
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.7",
|
||||
"@astrojs/mdx": "5.0.0",
|
||||
"@astrojs/node": "10.0.1",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@better-auth/sso": "1.5.5",
|
||||
"@better-auth/oauth-provider": "1.6.11",
|
||||
"@better-auth/sso": "1.6.11",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@@ -90,7 +88,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.5.5",
|
||||
"better-auth": "1.6.11",
|
||||
"buffer": "^6.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -188,6 +188,108 @@ function verify0011Migration(db: any) {
|
||||
assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'");
|
||||
}
|
||||
|
||||
function seedPre0012Database(db: any) {
|
||||
// The harness has already run migrations 0000-0011, so the legacy
|
||||
// oidc-provider tables exist. Seed a registered client (with the legacy
|
||||
// comma-separated redirect_urls format) plus the related token/consent rows
|
||||
// to exercise the create/transform/drop paths in 0012.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')");
|
||||
db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, disabled, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', 'https://example.com/callback,https://example.com/cb2', 'web', false, 'u1')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'tok', 'rtok', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')");
|
||||
db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('cons1', 'u1', 'client-1', '[\"repo\"]', true)");
|
||||
}
|
||||
|
||||
function verify0012Migration(db: any) {
|
||||
// Old provider tables are dropped.
|
||||
for (const table of ["oauth_applications", "oauth_consent"]) {
|
||||
const row = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(table) as { name: string } | null;
|
||||
assert(!row, `Expected ${table} table to be dropped after migration`);
|
||||
}
|
||||
|
||||
// New provider tables exist.
|
||||
for (const table of ["oauth_clients", "oauth_access_tokens", "oauth_refresh_tokens", "oauth_consents", "jwks"]) {
|
||||
const row = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(table) as { name: string } | null;
|
||||
assert(row, `Expected ${table} table to exist after migration`);
|
||||
}
|
||||
|
||||
// The registered client is preserved and its redirect URIs converted from
|
||||
// the legacy comma-separated string into a JSON string[].
|
||||
const client = db
|
||||
.query("SELECT client_id, client_secret, name, redirect_uris, type, user_id FROM oauth_clients WHERE id = 'app1'")
|
||||
.get() as { client_id: string; client_secret: string; name: string; redirect_uris: string; type: string; user_id: string } | null;
|
||||
assert(client, "Expected migrated oauth_clients row for app1");
|
||||
assert(client.client_id === "client-1", "Expected client_id to be preserved");
|
||||
assert(client.name === "Example App", "Expected client name to be preserved");
|
||||
assert(client.user_id === "u1", "Expected owner user_id to be preserved");
|
||||
|
||||
const uris = JSON.parse(client.redirect_uris);
|
||||
assert(
|
||||
Array.isArray(uris) && uris.length === 2 && uris[0] === "https://example.com/callback" && uris[1] === "https://example.com/cb2",
|
||||
`Expected redirect_uris to be a JSON array of the two callbacks, got ${client.redirect_uris}`,
|
||||
);
|
||||
|
||||
// The reshaped tables accept the new column layout.
|
||||
db.run("INSERT INTO oauth_clients (id, client_id, redirect_uris) VALUES ('app2', 'client-2', '[\"https://example.com/cb\"]')");
|
||||
db.run("INSERT INTO oauth_refresh_tokens (id, token, client_id, user_id, scopes) VALUES ('rt1', 'refresh-1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, token, client_id, user_id, scopes) VALUES ('at1', 'access-1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_consents (id, client_id, user_id, scopes) VALUES ('co1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO jwks (id, public_key, private_key) VALUES ('jwk1', 'public', 'private')");
|
||||
}
|
||||
|
||||
function seedPre0013Database(db: any) {
|
||||
// Migrations 0000-0012 have run, so sso_providers lacks samlConfig /
|
||||
// domainVerified and the organizations table still carries the inherited
|
||||
// DEFAULT '' on normalized_name from 0007. Seed both so the table-rebuild
|
||||
// and the column-adds can be verified end-to-end.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-sso', 'sso@example.com', 'sso', 'SSO User')");
|
||||
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre13', 'u-sso', 'Default', 1, '{}', '{}', '{}', '{}')");
|
||||
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso-pre13', 'https://idp.example.com', 'example.com', '{\"clientId\":\"x\"}', 'u-sso', 'idp-pre13')");
|
||||
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre13', 'u-sso', 'cfg-pre13', 'Example', 'https://example.com/a.png', 'example')");
|
||||
}
|
||||
|
||||
function verify0013Migration(db: any) {
|
||||
// New columns on sso_providers.
|
||||
const ssoCols = db
|
||||
.query("PRAGMA table_info(sso_providers)")
|
||||
.all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
|
||||
const saml = ssoCols.find((c) => c.name === "saml_config");
|
||||
const domainVerified = ssoCols.find((c) => c.name === "domain_verified");
|
||||
assert(saml, "Expected sso_providers.saml_config column to exist");
|
||||
assert(saml.notnull === 0, "Expected saml_config to be nullable");
|
||||
assert(domainVerified, "Expected sso_providers.domain_verified column to exist");
|
||||
assert(domainVerified.notnull === 1, "Expected domain_verified to be NOT NULL");
|
||||
assert(
|
||||
domainVerified.dflt_value === "true",
|
||||
`Expected domain_verified DEFAULT true, got ${domainVerified.dflt_value}`,
|
||||
);
|
||||
|
||||
// Pre-existing SSO row picked up the default (1 = true) on domain_verified.
|
||||
const ssoRow = db
|
||||
.query("SELECT provider_id, saml_config, domain_verified FROM sso_providers WHERE id = 'sso-pre13'")
|
||||
.get() as { provider_id: string; saml_config: string | null; domain_verified: number } | null;
|
||||
assert(ssoRow, "Expected pre-existing OIDC provider row to survive migration");
|
||||
assert(ssoRow.saml_config === null, `Expected saml_config NULL, got ${ssoRow.saml_config}`);
|
||||
assert(ssoRow.domain_verified === 1, `Expected domain_verified=1, got ${ssoRow.domain_verified}`);
|
||||
|
||||
// Organizations rebuild preserved the seeded row and dropped the inherited
|
||||
// DEFAULT '' on normalized_name (drizzle reconciles to schema.ts).
|
||||
const orgRow = db
|
||||
.query("SELECT id, normalized_name FROM organizations WHERE id = 'org-pre13'")
|
||||
.get() as { id: string; normalized_name: string } | null;
|
||||
assert(orgRow, "Expected pre-existing organization row to survive table rebuild");
|
||||
assert(orgRow.normalized_name === "example", `Expected organization normalized_name preserved, got ${orgRow.normalized_name}`);
|
||||
const orgCols = db
|
||||
.query("PRAGMA table_info(organizations)")
|
||||
.all() as Array<{ name: string; dflt_value: string | null }>;
|
||||
const normName = orgCols.find((c) => c.name === "normalized_name");
|
||||
assert(normName, "Expected organizations.normalized_name column to exist");
|
||||
assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`);
|
||||
}
|
||||
|
||||
const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
|
||||
"0009_nervous_tyger_tiger": {
|
||||
seed: seedPre0009Database,
|
||||
@@ -201,6 +303,14 @@ const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
|
||||
seed: seedPre0011Database,
|
||||
verify: verify0011Migration,
|
||||
},
|
||||
"0012_oauth_provider_migration": {
|
||||
seed: seedPre0012Database,
|
||||
verify: verify0012Migration,
|
||||
},
|
||||
"0013_slim_galactus": {
|
||||
seed: seedPre0013Database,
|
||||
verify: verify0013Migration,
|
||||
},
|
||||
};
|
||||
|
||||
function lintMigrations(selectedMigrations: Migration[]) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { KeyRound, LogOut, Mail } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChangePasswordDialog } from "./ChangePasswordDialog";
|
||||
import { ChangeEmailDialog } from "./ChangeEmailDialog";
|
||||
|
||||
export function AccountMenu() {
|
||||
const { user, logout, refreshUser } = useAuth();
|
||||
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const [emailOpen, setEmailOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setHasPassword(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const accounts = await authClient.listAccounts();
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(accounts) ? accounts : accounts?.data;
|
||||
setHasPassword(
|
||||
Array.isArray(list) && list.some((a) => a.providerId === "credential")
|
||||
);
|
||||
} catch {
|
||||
// Fail open: if we can't check, show the option rather than locking the
|
||||
// user out of changing their password.
|
||||
if (!cancelled) setHasPassword(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={withBase("/login")}>Login</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
toast.success("Logged out successfully");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="relative h-10 w-10 rounded-full p-0"
|
||||
>
|
||||
<Avatar className="h-full w-full">
|
||||
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
|
||||
<AvatarFallback>
|
||||
{(user.name || user.email || "U").charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{user.name && (
|
||||
<span className="text-sm font-medium leading-none">
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs leading-none text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{hasPassword && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPasswordOpen(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<KeyRound className="h-4 w-4 mr-2" />
|
||||
Change password
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setEmailOpen(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Change email
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleLogout} className="cursor-pointer">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasPassword && (
|
||||
<ChangePasswordDialog
|
||||
open={passwordOpen}
|
||||
onOpenChange={setPasswordOpen}
|
||||
/>
|
||||
)}
|
||||
<ChangeEmailDialog
|
||||
open={emailOpen}
|
||||
onOpenChange={setEmailOpen}
|
||||
currentEmail={user.email}
|
||||
onUpdated={refreshUser}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ChangeEmailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentEmail: string;
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
export function ChangeEmailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentEmail,
|
||||
onUpdated,
|
||||
}: ChangeEmailDialogProps) {
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setNewEmail("");
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newEmail.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("Please enter a new email");
|
||||
return;
|
||||
}
|
||||
if (trimmed.toLowerCase() === currentEmail.toLowerCase()) {
|
||||
toast.error("New email must differ from current email");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await authClient.changeEmail({ newEmail: trimmed });
|
||||
if (error) {
|
||||
toast.error(error.message || "Failed to change email");
|
||||
return;
|
||||
}
|
||||
toast.success("Email updated.");
|
||||
onUpdated?.();
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to change email");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change email</DialogTitle>
|
||||
<DialogDescription>
|
||||
Current: <span className="font-medium">{currentEmail}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-email">New email</Label>
|
||||
<Input
|
||||
id="new-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Update email"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ChangePasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({ open, onOpenChange }: ChangePasswordDialogProps) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [revokeOtherSessions, setRevokeOtherSessions] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setRevokeOtherSessions(true);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
toast.error("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("New passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (newPassword === currentPassword) {
|
||||
toast.error("New password must differ from current password");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message || "Failed to change password");
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
revokeOtherSessions
|
||||
? "Password updated. Other sessions signed out."
|
||||
: "Password updated."
|
||||
);
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to change password");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your current password and a new one. You'll stay signed in on this device.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">Current password</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">New password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm new password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="revoke-sessions"
|
||||
checked={revokeOtherSessions}
|
||||
onCheckedChange={(checked) => setRevokeOtherSessions(checked === true)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label htmlFor="revoke-sessions" className="text-sm font-normal cursor-pointer">
|
||||
Sign out other devices
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Update password"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,11 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { ModeToggle } from "@/components/theme/ModeToggle";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { Menu, LogOut, PanelRightOpen, PanelRightClose } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PanelRightOpen, PanelRightClose } from "lucide-react";
|
||||
import { AccountMenu } from "@/components/auth/AccountMenu";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface HeaderProps {
|
||||
@@ -26,7 +19,7 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse, isSidebarCollapsed, isSidebarOpen }: HeaderProps) {
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
const { isLoading } = useAuth();
|
||||
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
||||
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
||||
|
||||
@@ -47,13 +40,6 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
return isLiveEnabled ? 'Disable live refresh' : 'Enable live refresh';
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
toast.success("Logged out successfully");
|
||||
// Small delay to show the toast before redirecting
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
logout();
|
||||
};
|
||||
|
||||
// Auth buttons skeleton loader
|
||||
function AuthButtonsSkeleton() {
|
||||
return (
|
||||
@@ -141,32 +127,7 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
{isLoading ? (
|
||||
<AuthButtonsSkeleton />
|
||||
) : user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0">
|
||||
<Avatar className="h-full w-full">
|
||||
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
|
||||
<AvatarFallback>
|
||||
{(user.name || user.email || "U").charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={withBase('/login')}>Login</a>
|
||||
</Button>
|
||||
)}
|
||||
{isLoading ? <AuthButtonsSkeleton /> : <AccountMenu />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -90,41 +90,29 @@ export default function ConsentPage() {
|
||||
const handleConsent = async (accept: boolean) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// The OAuth provider redirected here with the authorization request in
|
||||
// the query string (client_id, scope, code). Hand that back via
|
||||
// `oauth_query` so the provider can resume the flow. It validates the
|
||||
// redirect URI server-side and returns the URL to navigate to — either
|
||||
// with an authorization code (accept) or an error (deny).
|
||||
const oauthQuery = window.location.search.replace(/^\?/, '');
|
||||
const result = await authClient.oauth2.consent({
|
||||
accept,
|
||||
oauth_query: oauthQuery,
|
||||
scope: Array.from(selectedScopes).join(' '),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'Consent failed');
|
||||
}
|
||||
|
||||
// The consent method should handle the redirect
|
||||
if (!accept) {
|
||||
// If denied, redirect back to the application with error
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirectUri = params.get('redirect_uri');
|
||||
|
||||
if (redirectUri && application) {
|
||||
// Validate redirect URI against authorized URIs
|
||||
const authorizedUris = parseRedirectUris(application.redirectURLs);
|
||||
|
||||
if (isValidRedirectUri(redirectUri, authorizedUris)) {
|
||||
try {
|
||||
// Parse and reconstruct the URL to ensure it's safe
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
|
||||
// Safe to redirect - URI has been validated and sanitized
|
||||
window.location.href = url.toString();
|
||||
} catch (e) {
|
||||
console.error('Failed to parse redirect URI:', e);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
} else {
|
||||
console.error('Unauthorized redirect URI:', redirectUri);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
}
|
||||
const redirectUri = (result.data as { redirect_uri?: string } | undefined)?.redirect_uri;
|
||||
if (redirectUri) {
|
||||
// The redirect URI is produced and validated server-side.
|
||||
window.location.href = redirectUri;
|
||||
} else if (!accept) {
|
||||
// No redirect target returned on denial — return to the app.
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@/lib/polyfills/buffer";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { oidcClient } from "better-auth/client/plugins";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
@@ -41,7 +41,7 @@ export const authClient = createAuthClient({
|
||||
})(),
|
||||
basePath: withBase('/api/auth'), // Explicitly set the base path
|
||||
plugins: [
|
||||
oidcClient(),
|
||||
oauthProviderClient(),
|
||||
ssoClient(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, mock, test, beforeEach } from "bun:test";
|
||||
|
||||
// Stub `./auth` so we can drive the response shape `mintSessionFromHeaders`
|
||||
// sees from the plugin endpoint without standing up the full Better Auth
|
||||
// stack + DB.
|
||||
const signInWithHeaderMock = mock<(args: unknown) => Promise<Response>>(
|
||||
async () => new Response(null, { status: 500 }),
|
||||
);
|
||||
|
||||
mock.module("@/lib/auth", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
signInWithHeader: signInWithHeaderMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { mintSessionFromHeaders } from "./auth-header-bridge";
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}): Request {
|
||||
return new Request("http://localhost/test", { headers });
|
||||
}
|
||||
|
||||
describe("mintSessionFromHeaders", () => {
|
||||
beforeEach(() => {
|
||||
signInWithHeaderMock.mockReset();
|
||||
});
|
||||
|
||||
test("returns user, session, and Set-Cookie values on a 200 response", async () => {
|
||||
signInWithHeaderMock.mockImplementation(async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
user: { id: "user-1", email: "u@example.com" },
|
||||
session: { id: "sess-1", userId: "user-1" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
// Modern runtimes coalesce multiple Set-Cookie via append.
|
||||
response.headers.append("set-cookie", "better-auth-session=abc; Path=/");
|
||||
response.headers.append("set-cookie", "better-auth-remember=1; Path=/");
|
||||
return response;
|
||||
});
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.user.id).toBe("user-1");
|
||||
expect(result?.session.id).toBe("sess-1");
|
||||
expect(result?.setCookies).toEqual([
|
||||
"better-auth-session=abc; Path=/",
|
||||
"better-auth-remember=1; Path=/",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns null when the endpoint responds non-2xx (header auth disabled / unauthorized)", async () => {
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () => new Response("Unauthorized", { status: 401 }),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the endpoint throws (transient failure)", async () => {
|
||||
signInWithHeaderMock.mockImplementation(async () => {
|
||||
throw new Error("upstream unreachable");
|
||||
});
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the response body is missing user or session", async () => {
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ token: "abc" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the body is malformed JSON", async () => {
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () =>
|
||||
new Response("not json at all", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns an empty setCookies array when no Set-Cookie headers were attached", async () => {
|
||||
// Defensive — should never happen in practice because the plugin
|
||||
// always calls setSessionCookie. If it does happen, we still want
|
||||
// the user/session to come through so SSR works on this request.
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
user: { id: "user-1" },
|
||||
session: { id: "sess-1" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.setCookies).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { auth } from "./auth";
|
||||
|
||||
export interface BridgeResult {
|
||||
user: any;
|
||||
session: any;
|
||||
setCookies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the `header-auth` plugin endpoint to mint a real Better Auth
|
||||
* session from trusted upstream headers (Authentik / Authelia /
|
||||
* oauth2-proxy / Caddy), and returns the user, session, and the
|
||||
* `Set-Cookie` headers for the middleware to forward onto the
|
||||
* outbound response.
|
||||
*
|
||||
* Fail-open: returns null on any failure (endpoint disabled, headers
|
||||
* missing, DB blip, malformed response). The middleware then sets
|
||||
* locals to null and the request proceeds as anonymous — broken
|
||||
* header auth must never lock everyone out of the cookie-auth path.
|
||||
*
|
||||
* Cookie extraction prefers `Response.headers.getSetCookie()` (Node 18+
|
||||
* fetch, undici). Older runtimes that only expose `get('set-cookie')`
|
||||
* fall through to the single-header form; that path coalesces all
|
||||
* Set-Cookie values into one comma-separated string, which is wrong
|
||||
* for cookies whose attributes contain commas (`Expires` does). We
|
||||
* accept that risk because: (a) Bun and the supported Node versions
|
||||
* for this project both implement `getSetCookie`, and (b) the
|
||||
* fallback only fires on truly ancient runtimes that aren't in our
|
||||
* support matrix.
|
||||
*/
|
||||
export async function mintSessionFromHeaders(
|
||||
request: Request,
|
||||
): Promise<BridgeResult | null> {
|
||||
try {
|
||||
const response = await auth.api.signInWithHeader({
|
||||
headers: request.headers,
|
||||
asResponse: true,
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data?.user || !data?.session) return null;
|
||||
|
||||
const setCookies =
|
||||
typeof response.headers.getSetCookie === "function"
|
||||
? response.headers.getSetCookie()
|
||||
: response.headers.get("set-cookie")
|
||||
? [response.headers.get("set-cookie") as string]
|
||||
: [];
|
||||
|
||||
return { user: data.user, session: data.session, setCookies };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { headerAuthPlugin } from "./auth-header-plugin";
|
||||
|
||||
describe("headerAuthPlugin", () => {
|
||||
test("registers the `header-auth` plugin id", () => {
|
||||
const plugin = headerAuthPlugin();
|
||||
expect(plugin.id).toBe("header-auth");
|
||||
});
|
||||
|
||||
test("exposes a `signInWithHeader` endpoint", () => {
|
||||
const plugin = headerAuthPlugin();
|
||||
expect(plugin.endpoints?.signInWithHeader).toBeDefined();
|
||||
});
|
||||
|
||||
test("mounts the endpoint at POST /sign-in/header", () => {
|
||||
// The Astro API route is /api/auth/<plugin-path>, so the resolved
|
||||
// URL the middleware bridge talks to is /api/auth/sign-in/header.
|
||||
// Locking that path down in a test prevents an accidental rename
|
||||
// from silently breaking the React SPA's auth flow.
|
||||
const plugin = headerAuthPlugin();
|
||||
const endpoint = plugin.endpoints?.signInWithHeader as unknown as {
|
||||
path: string;
|
||||
options: { method: string };
|
||||
};
|
||||
|
||||
expect(endpoint.path).toBe("/sign-in/header");
|
||||
expect(endpoint.options.method).toBe("POST");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BetterAuthPlugin } from "better-auth";
|
||||
import { APIError, createAuthEndpoint } from "better-auth/api";
|
||||
import { setSessionCookie } from "better-auth/cookies";
|
||||
import { authenticateWithHeaders, isHeaderAuthEnabled } from "./auth-header";
|
||||
|
||||
/**
|
||||
* Better Auth plugin that bridges header / forward authentication into a
|
||||
* real Better Auth session.
|
||||
*
|
||||
* Why this exists: the Astro middleware historically populated
|
||||
* `context.locals.user` from trusted upstream headers (Authentik /
|
||||
* Authelia / oauth2-proxy / Caddy), but never minted a Better Auth
|
||||
* session. Server-rendered pages saw the user, but the React SPA's
|
||||
* `/api/auth/get-session` call hit Better Auth's handler — which only
|
||||
* reads its session cookie — and got `null`. The auth guard then
|
||||
* bounced to `/login`, so header auth was end-to-end broken.
|
||||
*
|
||||
* This plugin exposes `POST /sign-in/header`, which the middleware
|
||||
* calls once per cold request (no cookie yet) when header auth is
|
||||
* enabled. It verifies the trusted headers, creates a real session
|
||||
* row via `internalAdapter.createSession`, and attaches the
|
||||
* `Set-Cookie` to the response. The middleware then forwards that
|
||||
* cookie to the outbound Astro response, so the SPA's next call to
|
||||
* `get-session` carries the cookie and works.
|
||||
*
|
||||
* The endpoint trusts whatever upstream sets the configured headers —
|
||||
* the security model here is "the operator controls the reverse
|
||||
* proxy." Make sure the proxy strips inbound copies of these headers
|
||||
* before forwarding (documented in docs/SSO-OIDC-SETUP.md).
|
||||
*/
|
||||
export const headerAuthPlugin = () =>
|
||||
({
|
||||
id: "header-auth",
|
||||
endpoints: {
|
||||
signInWithHeader: createAuthEndpoint(
|
||||
"/sign-in/header",
|
||||
{ method: "POST" },
|
||||
async (ctx) => {
|
||||
if (!isHeaderAuthEnabled()) {
|
||||
throw new APIError("NOT_FOUND", {
|
||||
message: "Header authentication is not enabled",
|
||||
});
|
||||
}
|
||||
|
||||
const headers = ctx.request?.headers ?? ctx.headers;
|
||||
if (!headers) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Request headers unavailable",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await authenticateWithHeaders(headers);
|
||||
if (!user) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "Header authentication failed",
|
||||
});
|
||||
}
|
||||
|
||||
const session = await ctx.context.internalAdapter.createSession(
|
||||
user.id,
|
||||
);
|
||||
if (!session) {
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Failed to create session",
|
||||
});
|
||||
}
|
||||
|
||||
await setSessionCookie(ctx, { session, user });
|
||||
|
||||
return ctx.json({
|
||||
token: session.token,
|
||||
user,
|
||||
session,
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
}) satisfies BetterAuthPlugin;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import {
|
||||
extractUserFromHeaders,
|
||||
isHeaderAuthEnabled,
|
||||
getHeaderAuthConfig,
|
||||
} from "./auth-header";
|
||||
|
||||
// `auth-header` reads config from `process.env` at call time. We snapshot
|
||||
// the relevant keys and restore them after each test so cases don't bleed.
|
||||
const HEADER_ENV_KEYS = [
|
||||
"HEADER_AUTH_ENABLED",
|
||||
"HEADER_AUTH_AUTO_PROVISION",
|
||||
"HEADER_AUTH_USER_HEADER",
|
||||
"HEADER_AUTH_EMAIL_HEADER",
|
||||
"HEADER_AUTH_NAME_HEADER",
|
||||
"HEADER_AUTH_ALLOWED_DOMAINS",
|
||||
] as const;
|
||||
|
||||
let savedEnv: Partial<Record<(typeof HEADER_ENV_KEYS)[number], string | undefined>> = {};
|
||||
|
||||
function setEnv(vars: Partial<Record<(typeof HEADER_ENV_KEYS)[number], string>>) {
|
||||
for (const key of HEADER_ENV_KEYS) {
|
||||
if (key in vars) {
|
||||
process.env[key] = vars[key]!;
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = Object.fromEntries(
|
||||
HEADER_ENV_KEYS.map((k) => [k, process.env[k]]),
|
||||
) as typeof savedEnv;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of HEADER_ENV_KEYS) {
|
||||
const v = savedEnv[key];
|
||||
if (v === undefined) delete process.env[key];
|
||||
else process.env[key] = v;
|
||||
}
|
||||
});
|
||||
|
||||
describe("isHeaderAuthEnabled", () => {
|
||||
test("returns false when HEADER_AUTH_ENABLED is unset", () => {
|
||||
setEnv({});
|
||||
expect(isHeaderAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when HEADER_AUTH_ENABLED is anything other than the string 'true'", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "1" });
|
||||
expect(isHeaderAuthEnabled()).toBe(false);
|
||||
setEnv({ HEADER_AUTH_ENABLED: "yes" });
|
||||
expect(isHeaderAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true only for HEADER_AUTH_ENABLED='true' exactly", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "true" });
|
||||
expect(isHeaderAuthEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractUserFromHeaders", () => {
|
||||
test("returns null when header auth is disabled", () => {
|
||||
setEnv({});
|
||||
const headers = new Headers({ "X-Authentik-Username": "u" });
|
||||
expect(extractUserFromHeaders(headers)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the configured user header is absent", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "true" });
|
||||
const headers = new Headers({ "X-Some-Other-Header": "u" });
|
||||
expect(extractUserFromHeaders(headers)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns username, email, and name from default Authentik headers", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "true" });
|
||||
const headers = new Headers({
|
||||
"X-Authentik-Username": "alice",
|
||||
"X-Authentik-Email": "alice@example.com",
|
||||
"X-Authentik-Name": "Alice Q",
|
||||
});
|
||||
|
||||
expect(extractUserFromHeaders(headers)).toEqual({
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
name: "Alice Q",
|
||||
});
|
||||
});
|
||||
|
||||
test("respects HEADER_AUTH_USER_HEADER override (Caddy / caddy-security style)", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_USER_HEADER: "X-Token-User-Email",
|
||||
HEADER_AUTH_EMAIL_HEADER: "X-Token-User-Email",
|
||||
HEADER_AUTH_NAME_HEADER: "X-Token-User-Name",
|
||||
});
|
||||
const headers = new Headers({
|
||||
"X-Token-User-Email": "bob@example.com",
|
||||
"X-Token-User-Name": "Bob",
|
||||
});
|
||||
|
||||
// lanrat's reported config: username and email are both pulled from
|
||||
// the same header. Both should resolve to that value.
|
||||
expect(extractUserFromHeaders(headers)).toEqual({
|
||||
username: "bob@example.com",
|
||||
email: "bob@example.com",
|
||||
name: "Bob",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects when email domain is not on the allow list", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example",
|
||||
});
|
||||
const headers = new Headers({
|
||||
"X-Authentik-Username": "evil",
|
||||
"X-Authentik-Email": "evil@elsewhere.test",
|
||||
});
|
||||
|
||||
expect(extractUserFromHeaders(headers)).toBeNull();
|
||||
});
|
||||
|
||||
test("accepts when email domain matches the allow list", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example",
|
||||
});
|
||||
const headers = new Headers({
|
||||
"X-Authentik-Username": "alice",
|
||||
"X-Authentik-Email": "alice@corp.example",
|
||||
});
|
||||
|
||||
expect(extractUserFromHeaders(headers)).toEqual({
|
||||
username: "alice",
|
||||
email: "alice@corp.example",
|
||||
name: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHeaderAuthConfig", () => {
|
||||
test("merges env overrides over defaults without leaking unset env values", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_USER_HEADER: "X-Forwarded-User",
|
||||
});
|
||||
|
||||
const config = getHeaderAuthConfig();
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.userHeader).toBe("X-Forwarded-User");
|
||||
// Unset overrides should fall back to defaults, not become undefined.
|
||||
expect(config.emailHeader).toBe("X-Authentik-Email");
|
||||
expect(config.nameHeader).toBe("X-Authentik-Name");
|
||||
});
|
||||
});
|
||||
+112
-11
@@ -1,11 +1,13 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { oidcProvider } from "better-auth/plugins";
|
||||
import { jwt } from "better-auth/plugins";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { db, users } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBase } from "./base-path";
|
||||
import { headerAuthPlugin } from "./auth-header-plugin";
|
||||
|
||||
/**
|
||||
* Resolves the list of trusted origins for Better Auth CSRF validation.
|
||||
@@ -73,6 +75,23 @@ export async function resolveTrustedOrigins(request?: Request): Promise<string[]
|
||||
return uniqueOrigins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Better Auth logger level from BETTER_AUTH_LOG_LEVEL.
|
||||
* Returns undefined for unset/invalid values so Better Auth falls back
|
||||
* to its built-in default ("warn"). "success" is intentionally excluded
|
||||
* — it is an output level, not a valid threshold.
|
||||
*/
|
||||
function resolveAuthLogLevel(): "debug" | "info" | "warn" | "error" | undefined {
|
||||
const raw = process.env.BETTER_AUTH_LOG_LEVEL?.trim().toLowerCase();
|
||||
if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") {
|
||||
return raw;
|
||||
}
|
||||
if (raw) {
|
||||
console.warn(`Invalid BETTER_AUTH_LOG_LEVEL: "${raw}", using default ("warn")`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
// Database configuration
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -84,6 +103,17 @@ export const auth = betterAuth({
|
||||
// Secret for signing tokens
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
|
||||
// Logger configuration.
|
||||
//
|
||||
// Better Auth ships its own logger (it does NOT read the `DEBUG` env
|
||||
// var / the `debug` npm package — `DEBUG=better-auth:*` is a no-op).
|
||||
// The default level is "warn", so SSO/OIDC debug and info messages
|
||||
// are hidden out of the box. Set BETTER_AUTH_LOG_LEVEL=debug to surface
|
||||
// the full sign-in / callback trace when troubleshooting SSO.
|
||||
logger: {
|
||||
level: resolveAuthLogLevel(),
|
||||
},
|
||||
|
||||
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
||||
baseURL: (() => {
|
||||
const url = process.env.BETTER_AUTH_URL;
|
||||
@@ -145,28 +175,79 @@ export const auth = betterAuth({
|
||||
input: false, // Don't show in signup form - we'll derive from email
|
||||
}
|
||||
},
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
// Email verification isn't wired up (sendResetPassword is a TODO),
|
||||
// so allow direct updates. Safe here because emails stay unverified.
|
||||
updateEmailWithoutVerification: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Account linking configuration.
|
||||
//
|
||||
// Lets a user who first registered with email/password sign in through SSO
|
||||
// and land on the *same* account, instead of being bounced back to /login.
|
||||
// Better Auth's auto-link path (link-account.mjs) refuses unless BOTH sides
|
||||
// pass:
|
||||
// - upstream: provider is "trusted" (either listed in `trustedProviders`
|
||||
// or the SSO plugin marks it trusted via domainVerified +
|
||||
// domain match) OR userInfo.emailVerified === true
|
||||
// - local: existing user is verified (or requireLocalEmailVerified=false)
|
||||
//
|
||||
// We don't wire an email-verification flow, so the local admin always has
|
||||
// emailVerified=false — `requireLocalEmailVerified: false` is required.
|
||||
//
|
||||
// We deliberately do NOT use the catch-all `trustedProviders` list (which
|
||||
// would blanket-trust every registered IdP). Instead the SSO provider's
|
||||
// own `domainVerified` flag — set to true at registration time, scoped to
|
||||
// the operator-supplied `domain` — gates linking. The SSO plugin enforces
|
||||
// `validateEmailDomain(userInfo.email, provider.domain)` on top of it, so
|
||||
// a sign-in is only auto-linked when (a) the operator vouched for the IdP
|
||||
// by registering it, and (b) the user's email actually belongs to the
|
||||
// domain that was vouched for. Cross-domain claims from a compromised or
|
||||
// permissive IdP do not silently absorb local accounts. (Same-domain
|
||||
// claims still require the operator to trust their IdP's identity model.)
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
requireLocalEmailVerified: false,
|
||||
// Keep the default (false): never link accounts whose emails differ.
|
||||
allowDifferentEmails: false,
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins configuration
|
||||
plugins: [
|
||||
// OIDC Provider plugin - allows this app to act as an OIDC provider
|
||||
oidcProvider({
|
||||
// JWT plugin — provides the JWKS keypair that the OAuth provider uses to
|
||||
// sign OIDC id_tokens with an asymmetric key (what most relying parties
|
||||
// expect). Required by @better-auth/oauth-provider unless disableJwtPlugin
|
||||
// is set. Keys are persisted in the `jwks` table.
|
||||
jwt(),
|
||||
|
||||
// OAuth 2.1 / OIDC Provider — allows this app to act as an identity
|
||||
// provider for other applications. Replaces the deprecated `oidcProvider`
|
||||
// plugin (which Better Auth will remove in a future release). Client and
|
||||
// consent records now live in the oauth_clients / oauth_consents tables.
|
||||
oauthProvider({
|
||||
loginPage: withBase("/login"),
|
||||
consentPage: withBase("/oauth/consent"),
|
||||
// Allow dynamic client registration for flexibility
|
||||
allowDynamicClientRegistration: true,
|
||||
// Note: trustedClients would be configured here if Better Auth supports it
|
||||
// For now, we'll use dynamic registration
|
||||
// Customize user info claims based on scopes
|
||||
getAdditionalUserInfoClaim: (user, scopes) => {
|
||||
// Mirror the old getAdditionalUserInfoClaim: expose our extra `username`
|
||||
// field on both the userinfo endpoint and the id_token when the
|
||||
// "profile" scope is granted.
|
||||
customUserInfoClaims: ({ user, scopes }) => {
|
||||
const claims: Record<string, any> = {};
|
||||
if (scopes.includes("profile")) {
|
||||
claims.username = user.username;
|
||||
}
|
||||
if (scopes.includes("profile")) claims.username = (user as any).username;
|
||||
return claims;
|
||||
},
|
||||
customIdTokenClaims: ({ user, scopes }) => {
|
||||
const claims: Record<string, any> = {};
|
||||
if (scopes.includes("profile")) claims.username = (user as any).username;
|
||||
return claims;
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
// SSO plugin - allows users to authenticate with external OIDC providers
|
||||
sso({
|
||||
// Provision new users when they sign in with SSO
|
||||
@@ -198,7 +279,27 @@ export const auth = betterAuth({
|
||||
disableImplicitSignUp: false,
|
||||
// Trust email_verified claims from the upstream provider so we can link by matching email
|
||||
trustEmailVerified: true,
|
||||
// Surface `domainVerified` to the model so account-linking can read it.
|
||||
//
|
||||
// Better Auth's adapter transformOutput (factory.mjs) only copies fields
|
||||
// that are declared in the plugin's model schema; the SSO plugin only
|
||||
// declares `domainVerified` when this option is enabled. Without it the
|
||||
// column is in the DB but stripped from the returned provider object, so
|
||||
// the trust check `"domainVerified" in provider` is silently false and
|
||||
// sign-ins land on /?error=UNKNOWN. We don't use the DNS-based
|
||||
// verify-domain flow this option also exposes — we set
|
||||
// `domainVerified: true` directly in src/pages/api/auth/sso/register.ts
|
||||
// after the plugin's create, scoped by the operator-supplied `domain`.
|
||||
domainVerification: { enabled: true },
|
||||
}),
|
||||
|
||||
// Header / forward authentication bridge. Exposes
|
||||
// POST /api/auth/sign-in/header so the middleware can mint a real
|
||||
// Better Auth session from trusted upstream headers (Authentik /
|
||||
// Authelia / oauth2-proxy / Caddy). Without this the SPA's
|
||||
// /api/auth/get-session call returns null on header-auth-only
|
||||
// requests and bounces the user to /login. See auth-header-plugin.ts.
|
||||
headerAuthPlugin(),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
+4
-2
@@ -123,9 +123,11 @@ export {
|
||||
accounts,
|
||||
verificationTokens,
|
||||
verifications,
|
||||
oauthApplications,
|
||||
oauthClients,
|
||||
oauthAccessTokens,
|
||||
oauthConsent,
|
||||
oauthRefreshTokens,
|
||||
oauthConsents,
|
||||
jwkss,
|
||||
ssoProviders,
|
||||
rateLimits
|
||||
} from "./schema";
|
||||
+107
-46
@@ -618,69 +618,121 @@ export const verifications = sqliteTable("verifications", {
|
||||
|
||||
// ===== OIDC Provider Tables =====
|
||||
|
||||
// OAuth Applications table
|
||||
export const oauthApplications = sqliteTable("oauth_applications", {
|
||||
// ===== OAuth 2.1 / OIDC Provider tables (@better-auth/oauth-provider) =====
|
||||
//
|
||||
// These back the OAuth/OIDC *provider* feature (gitea-mirror acting as an
|
||||
// identity provider for other apps). They are managed entirely by Better
|
||||
// Auth's drizzle adapter, so:
|
||||
// - the exported binding name must equal the plugin model name pluralized
|
||||
// under `usePlural: true` (oauthClient -> oauthClients, jwks -> jwkss);
|
||||
// - the object property names must match the plugin field names (camelCase),
|
||||
// while the SQL column names may be snake_case;
|
||||
// - `string[]` and `json` fields are serialized to JSON text by the adapter,
|
||||
// so they are plain `text` columns here.
|
||||
//
|
||||
// Migrated from the deprecated `oidc-provider` plugin (tables
|
||||
// oauth_applications / oauth_access_tokens / oauth_consent). See the
|
||||
// accompanying Drizzle migration for the data-preserving upgrade path.
|
||||
|
||||
// OAuth clients (replaces the old `oauth_applications` table)
|
||||
export const oauthClients = sqliteTable("oauth_clients", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id").notNull().unique(),
|
||||
clientSecret: text("client_secret").notNull(),
|
||||
name: text("name").notNull(),
|
||||
redirectURLs: text("redirect_urls").notNull(), // Comma-separated list
|
||||
metadata: text("metadata"), // JSON string
|
||||
type: text("type").notNull(), // web, mobile, etc
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
userId: text("user_id"), // Optional - owner of the application
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
clientSecret: text("client_secret"),
|
||||
name: text("name"),
|
||||
disabled: integer("disabled", { mode: "boolean" }).default(false),
|
||||
skipConsent: integer("skip_consent", { mode: "boolean" }),
|
||||
enableEndSession: integer("enable_end_session", { mode: "boolean" }),
|
||||
subjectType: text("subject_type"),
|
||||
scopes: text("scopes"), // JSON string[]
|
||||
userId: text("user_id").references(() => users.id),
|
||||
uri: text("uri"),
|
||||
icon: text("icon"),
|
||||
contacts: text("contacts"), // JSON string[]
|
||||
tos: text("tos"),
|
||||
policy: text("policy"),
|
||||
softwareId: text("software_id"),
|
||||
softwareVersion: text("software_version"),
|
||||
softwareStatement: text("software_statement"),
|
||||
redirectUris: text("redirect_uris").notNull(), // JSON string[]
|
||||
postLogoutRedirectUris: text("post_logout_redirect_uris"), // JSON string[]
|
||||
tokenEndpointAuthMethod: text("token_endpoint_auth_method"),
|
||||
grantTypes: text("grant_types"), // JSON string[]
|
||||
responseTypes: text("response_types"), // JSON string[]
|
||||
public: integer("public", { mode: "boolean" }),
|
||||
type: text("type"),
|
||||
requirePKCE: integer("require_pkce", { mode: "boolean" }),
|
||||
referenceId: text("reference_id"),
|
||||
metadata: text("metadata"), // JSON
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_applications_client_id").on(table.clientId),
|
||||
index("idx_oauth_applications_user_id").on(table.userId),
|
||||
index("idx_oauth_clients_client_id").on(table.clientId),
|
||||
index("idx_oauth_clients_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Access Tokens table
|
||||
// OAuth access tokens
|
||||
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
accessToken: text("access_token").notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }).notNull(),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
|
||||
token: text("token").unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
sessionId: text("session_id"),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
refreshId: text("refresh_id"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
}, (table) => [
|
||||
index("idx_oauth_access_tokens_access_token").on(table.accessToken),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
index("idx_oauth_access_tokens_token").on(table.token),
|
||||
index("idx_oauth_access_tokens_client_id").on(table.clientId),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Consent table
|
||||
export const oauthConsent = sqliteTable("oauth_consent", {
|
||||
// OAuth refresh tokens (new in the OAuth 2.1 provider)
|
||||
export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
token: text("token").notNull().unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
consentGiven: integer("consent_given", { mode: "boolean" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
sessionId: text("session_id"),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
revoked: integer("revoked", { mode: "timestamp" }),
|
||||
authTime: integer("auth_time", { mode: "timestamp" }),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
}, (table) => [
|
||||
index("idx_oauth_consent_user_id").on(table.userId),
|
||||
index("idx_oauth_consent_client_id").on(table.clientId),
|
||||
index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
|
||||
index("idx_oauth_refresh_tokens_token").on(table.token),
|
||||
index("idx_oauth_refresh_tokens_client_id").on(table.clientId),
|
||||
index("idx_oauth_refresh_tokens_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth consent records
|
||||
export const oauthConsents = sqliteTable("oauth_consents", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id").notNull(),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_consents_client_id").on(table.clientId),
|
||||
index("idx_oauth_consents_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// JWKS keypairs for signing OIDC id_tokens (better-auth `jwt` plugin).
|
||||
// Model name "jwks" pluralizes to the binding name "jwkss" under usePlural,
|
||||
// while the physical table stays "jwks".
|
||||
export const jwkss = sqliteTable("jwks", {
|
||||
id: text("id").primaryKey(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
// ===== SSO Provider Tables =====
|
||||
|
||||
// SSO Providers table
|
||||
@@ -689,6 +741,15 @@ export const ssoProviders = sqliteTable("sso_providers", {
|
||||
issuer: text("issuer").notNull(),
|
||||
domain: text("domain").notNull(),
|
||||
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
|
||||
// The upgraded @better-auth/sso plugin writes this on every insert (null for OIDC providers).
|
||||
// Drizzle's adapter rejects unknown fields, so the column must exist.
|
||||
samlConfig: text("saml_config"),
|
||||
// Used by the SSO plugin's account-linking trust check: a sign-in is treated
|
||||
// as trusted when this is true AND the user's email domain matches `domain`
|
||||
// above. We set this to true on register (see /api/auth/sso/register.ts) so
|
||||
// domain-scoped auto-linking works out of the box; the column default keeps
|
||||
// existing rows trusted after upgrade.
|
||||
domainVerified: integer("domain_verified", { mode: "boolean" }).notNull().default(true),
|
||||
userId: text("user_id").notNull(), // Admin who created this provider
|
||||
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
|
||||
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
|
||||
|
||||
@@ -421,9 +421,18 @@ export async function syncGiteaRepoEnhanced({
|
||||
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
|
||||
}
|
||||
|
||||
// Parse repository metadata state up-front. The force-push
|
||||
// detection below needs the acknowledgedDeletions list to suppress
|
||||
// already-handled deletions, and a successful snapshot needs to
|
||||
// record new entries back into the same state object. The
|
||||
// metadata-mirroring block downstream reuses this same variable.
|
||||
const metadataState = parseRepositoryMetadataState(repository.metadata);
|
||||
let metadataUpdated = false;
|
||||
|
||||
// ---- Smart backup strategy with force-push detection ----
|
||||
const backupStrategy = resolveBackupStrategy(config);
|
||||
let forcePushDetected = false;
|
||||
let forcePushAffected: ReadonlyArray<{ name: string; reason: string; giteaSha: string }> = [];
|
||||
|
||||
if (backupStrategy !== "disabled") {
|
||||
// Run force-push detection if the strategy requires it
|
||||
@@ -441,9 +450,11 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit: fpOctokit,
|
||||
githubOwner: repository.owner,
|
||||
githubRepo: repository.name,
|
||||
acknowledgedDeletions: metadataState.acknowledgedDeletions,
|
||||
});
|
||||
|
||||
forcePushDetected = detectionResult.detected;
|
||||
forcePushAffected = detectionResult.affectedBranches;
|
||||
|
||||
if (detectionResult.skipped) {
|
||||
console.log(
|
||||
@@ -457,6 +468,39 @@ export async function syncGiteaRepoEnhanced({
|
||||
`[Sync] Force-push detected on ${repository.name}: ${branchNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Record each detected "deleted" branch into the
|
||||
// acknowledged list. We do this based on detection alone,
|
||||
// BEFORE the backup attempt below, so that:
|
||||
// - concurrent sync invocations both add the entry
|
||||
// (the second one whose `createPreSyncBundleBackup`
|
||||
// short-circuits would otherwise skip the push and
|
||||
// race its empty in-memory state onto the metadata
|
||||
// row, undoing the first invocation's write);
|
||||
// - if the backup later fails the deletion is still
|
||||
// acknowledged (the branch is genuinely gone from
|
||||
// GitHub; not re-detecting it next sync is correct
|
||||
// regardless of whether THIS invocation's backup
|
||||
// succeeded — prior backups still exist on disk and
|
||||
// the user can re-trigger one manually if needed).
|
||||
const newAcknowledged = forcePushAffected
|
||||
.filter((b) => b.reason === "deleted")
|
||||
.map((b) => ({ branch: b.name, giteaSha: b.giteaSha }));
|
||||
if (newAcknowledged.length > 0) {
|
||||
const existingKeys = new Set(
|
||||
metadataState.acknowledgedDeletions.map(
|
||||
(e) => `${e.branch}@${e.giteaSha}`,
|
||||
),
|
||||
);
|
||||
for (const entry of newAcknowledged) {
|
||||
const key = `${entry.branch}@${entry.giteaSha}`;
|
||||
if (!existingKeys.has(key)) {
|
||||
metadataState.acknowledgedDeletions.push(entry);
|
||||
existingKeys.add(key);
|
||||
metadataUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[Sync] Skipping force-push detection for ${repository.name}: no GitHub token`,
|
||||
@@ -519,8 +563,17 @@ export async function syncGiteaRepoEnhanced({
|
||||
repositoryName: repository.name,
|
||||
message: `Snapshot created for ${repository.name}`,
|
||||
details: `Pre-sync snapshot created at ${backupResult.bundlePath}.`,
|
||||
status: "syncing",
|
||||
// The snapshot is already complete (createPreSyncBundleBackup
|
||||
// returned above). Using "syncing" here left the row in a
|
||||
// non-terminal state and there was no later code path to
|
||||
// advance it, so every force-push-triggered backup leaked an
|
||||
// orphan row that accumulated on the jobs page forever.
|
||||
status: "synced",
|
||||
});
|
||||
// Note: acknowledging the deletion happens earlier (right
|
||||
// after detection) so concurrent sync invocations both
|
||||
// record the entry. Don't move it back here — see the
|
||||
// detection block above for the rationale.
|
||||
} catch (backupError) {
|
||||
const errorMessage =
|
||||
backupError instanceof Error ? backupError.message : String(backupError);
|
||||
@@ -584,8 +637,9 @@ export async function syncGiteaRepoEnhanced({
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
});
|
||||
|
||||
const metadataState = parseRepositoryMetadataState(repository.metadata);
|
||||
let metadataUpdated = false;
|
||||
// metadataState + metadataUpdated are hoisted above the backup
|
||||
// strategy block so force-push detection can read/write the
|
||||
// acknowledged-deletions list. Don't shadow them here.
|
||||
const skipMetadataForStarred =
|
||||
repository.isStarred && config.githubConfig?.starredCodeOnly;
|
||||
let metadataOctokit: Octokit | null = null;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Regression test for duplicate-issue creation on retry-after-deadlock.
|
||||
*
|
||||
* `mirrorGitRepoIssuesToGitea` pre-fetches all existing Gitea issues
|
||||
* into `giteaIssueByGitHubNumber` ONCE at function entry, then iterates
|
||||
* per-issue via `processWithRetry`. Each iteration uses the cached map
|
||||
* to decide CREATE vs PATCH.
|
||||
*
|
||||
* The bug: when Gitea's CreateIssue handler commits the issue insert
|
||||
* in one transaction and then deadlocks on the addLabel / repository
|
||||
* counter update in a second transaction, the issue row is committed
|
||||
* and visible — but the in-memory map is never refreshed between
|
||||
* retries. So `processWithRetry` would call the callback again,
|
||||
* `existingIssue` is still `undefined` from the stale map, and a fresh
|
||||
* `httpPost` creates a duplicate.
|
||||
*
|
||||
* Reproduces deterministically on MySQL (1213/40001) and PostgreSQL
|
||||
* (40P01). SQLite escapes because writes serialize globally.
|
||||
*
|
||||
* This test asserts on the *structure* of the source rather than
|
||||
* invoking the function, because behavioral tests for the issue-mirror
|
||||
* pipeline require heavy module mocks that pollute other test files
|
||||
* (bun's mock.module is process-wide). See
|
||||
* `gitea-mirror-failure-recovery.test.ts` for the same convention.
|
||||
*
|
||||
* The two structural guarantees this test enforces:
|
||||
* (1) Before the create-issue httpPost call, the code performs a
|
||||
* defensive recheck via httpGet that queries Gitea by
|
||||
* `[GH-ISSUE #N]` title marker — handles "previous attempt
|
||||
* committed the issue then threw" scenarios.
|
||||
* (2) After a successful httpPost create, the new issue is written
|
||||
* back into `giteaIssueByGitHubNumber` — handles "this attempt
|
||||
* created the issue, but a later step in the same callback
|
||||
* (e.g. comment sync) throws and triggers another retry"
|
||||
* scenarios.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(join(import.meta.dir, "gitea.ts"), "utf8");
|
||||
|
||||
/**
|
||||
* Locate the body of a function declaration by name. Walks from the
|
||||
* declaration, balances parens to skip the parameter list (which can
|
||||
* contain destructured object literals with their own braces), then
|
||||
* finds the body's opening brace and its matching close.
|
||||
*
|
||||
* Same helper as in `gitea-mirror-failure-recovery.test.ts`; kept
|
||||
* local to keep this test file self-contained.
|
||||
*/
|
||||
function extractFunctionBody(source: string, declarationStart: RegExp): string {
|
||||
const match = source.match(declarationStart);
|
||||
if (!match) {
|
||||
throw new Error(`Could not locate declaration ${declarationStart}`);
|
||||
}
|
||||
let i = match.index! + match[0].length;
|
||||
while (i < source.length && source[i] !== "(") i++;
|
||||
if (source[i] !== "(") {
|
||||
throw new Error(`No '(' after ${declarationStart}`);
|
||||
}
|
||||
let parenDepth = 0;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "(") parenDepth++;
|
||||
else if (source[i] === ")") {
|
||||
parenDepth--;
|
||||
if (parenDepth === 0) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (i < source.length && source[i] !== "{") i++;
|
||||
if (source[i] !== "{") {
|
||||
throw new Error(`No body '{' for ${declarationStart}`);
|
||||
}
|
||||
let braceDepth = 0;
|
||||
const startIdx = i;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "{") braceDepth++;
|
||||
else if (source[i] === "}") {
|
||||
braceDepth--;
|
||||
if (braceDepth === 0) {
|
||||
return source.slice(startIdx, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unterminated body for ${declarationStart}`);
|
||||
}
|
||||
|
||||
describe("issue dedup on retry-after-deadlock", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export const mirrorGitRepoIssuesToGitea = async\b/
|
||||
);
|
||||
|
||||
test("body contains the per-issue create branch we expect to guard", () => {
|
||||
// Sanity: make sure the test is looking at the right code path.
|
||||
// If these strings disappear due to a refactor, this test should
|
||||
// fail loudly so a human reviews whether the dedup guarantees
|
||||
// still hold in the new shape.
|
||||
expect(
|
||||
body.includes(
|
||||
"giteaIssueByGitHubNumber.get(issue.number)"
|
||||
),
|
||||
"expected the per-issue lookup against giteaIssueByGitHubNumber"
|
||||
).toBe(true);
|
||||
expect(
|
||||
body.match(/await httpPost\(\s*`\$\{config\.giteaConfig!\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/issues`/),
|
||||
"expected the create-issue httpPost call"
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("defensive recheck via httpGet runs BEFORE the create httpPost", () => {
|
||||
// The recheck must query Gitea by [GH-ISSUE #N] marker to catch
|
||||
// the partial-commit case. Without it, a deadlock-after-insert
|
||||
// returns 5xx, processWithRetry re-runs the callback, and the
|
||||
// create call produces a duplicate row.
|
||||
const recheckIdx = body.search(
|
||||
/await httpGet\([^)]*\[GH-ISSUE #\$\{issue\.number\}\]/
|
||||
);
|
||||
expect(
|
||||
recheckIdx,
|
||||
"defensive recheck via httpGet using [GH-ISSUE #N] marker must exist"
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// The recheck must come before the create httpPost in source order.
|
||||
// The first httpPost on the .../issues endpoint inside this
|
||||
// function body is the create call; we anchor against it.
|
||||
const createIdx = body.search(
|
||||
/await httpPost\(\s*`\$\{config\.giteaConfig!\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/issues`/
|
||||
);
|
||||
expect(createIdx, "create httpPost call must exist").toBeGreaterThanOrEqual(0);
|
||||
|
||||
expect(
|
||||
recheckIdx,
|
||||
"the recheck must run BEFORE the create call so it can short-circuit on partial-commit duplicates"
|
||||
).toBeLessThan(createIdx);
|
||||
});
|
||||
|
||||
test("recheck hit short-circuits via PATCH and updates the cache", () => {
|
||||
// When the recheck finds a hit (i.e. a previous failed attempt
|
||||
// already created this issue), the code should:
|
||||
// - cache the hit into giteaIssueByGitHubNumber so subsequent
|
||||
// retries within this run also find it
|
||||
// - go down the PATCH path (httpPatch) instead of httpPost
|
||||
// - log a recognisable line so operators can spot recovery
|
||||
expect(
|
||||
body.includes(
|
||||
"giteaIssueByGitHubNumber.set(issue.number, recheckHit)"
|
||||
) ||
|
||||
body.match(/giteaIssueByGitHubNumber\.set\(\s*issue\.number\s*,\s*recheckHit/),
|
||||
"recheck hit must be written back into giteaIssueByGitHubNumber"
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
body.match(/Recovered orphan from prior failed attempt/i),
|
||||
"a log line should make the recovery path visible in operator logs"
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("pre-fetch issues pagination uses Link header (not short-page heuristic)", () => {
|
||||
// The previous `if (pageIssues.length < issuesPerPage) break;`
|
||||
// heuristic was wrong in both directions:
|
||||
// - Gitea caps response size at `[api].MAX_RESPONSE_ITEMS`
|
||||
// (default 50), typically lower than `issuesPerPage` (100),
|
||||
// so the very first page already looks "short" and
|
||||
// pagination terminated after 50 items. Every issue past
|
||||
// that was misclassified as new and duplicated on every sync.
|
||||
// - Naive removal of that break, relying only on "break on
|
||||
// empty page", can loop forever because some Gitea endpoints
|
||||
// return the same data on every page when asked for a page
|
||||
// past the actual end (instead of returning []).
|
||||
//
|
||||
// The correct fix is to use the Link header (RFC 5988): if
|
||||
// `rel="next"` is absent, we're done.
|
||||
//
|
||||
// This test asserts:
|
||||
// - the broken short-page check is gone
|
||||
// - the existing-issues loop checks the Link header for next
|
||||
const issuesPaginationRegion = body.substring(
|
||||
body.indexOf("existingGiteaIssues.push"),
|
||||
body.indexOf("issuesPage += 1") + 30
|
||||
);
|
||||
expect(
|
||||
issuesPaginationRegion,
|
||||
"issues pagination region should be present"
|
||||
).not.toBe("");
|
||||
expect(
|
||||
/\bpageIssues\.length\s*<\s*issuesPerPage\b/.test(issuesPaginationRegion),
|
||||
"the short-page break (pageIssues.length < issuesPerPage) must be removed"
|
||||
).toBe(false);
|
||||
expect(
|
||||
/existingIssuesRes\.headers\.get\(\s*["']link["']\s*\)/.test(
|
||||
issuesPaginationRegion
|
||||
) && /rel="next"/.test(issuesPaginationRegion),
|
||||
"the issues pagination loop must use the Link header (rel=\"next\") " +
|
||||
"to decide whether to fetch the next page"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("per-issue comments pagination also uses Link header (not short-page heuristic)", () => {
|
||||
// Same correctness concerns as issues pagination above. The per-
|
||||
// issue comments endpoint is subject to the same Gitea page-size
|
||||
// cap, and naive empty-page detection has the same risk.
|
||||
expect(
|
||||
/\bpageComments\.length\s*<\s*commentsPerPage\b/.test(body),
|
||||
"the short-page break (pageComments.length < commentsPerPage) must be removed"
|
||||
).toBe(false);
|
||||
// Look only at the comments-fetch region (not the whole file) so
|
||||
// a future caller using a different response variable name in
|
||||
// another place won't false-positive this assertion.
|
||||
const commentsRegion = body.substring(
|
||||
body.indexOf("existingComments.push"),
|
||||
body.indexOf("commentsPage += 1") + 30
|
||||
);
|
||||
expect(
|
||||
commentsRegion,
|
||||
"comments pagination region should be present"
|
||||
).not.toBe("");
|
||||
expect(
|
||||
/existingCommentsRes\.headers\.get\(\s*["']link["']\s*\)/.test(
|
||||
commentsRegion
|
||||
) && /rel="next"/.test(commentsRegion),
|
||||
"the comments pagination loop must use the Link header (rel=\"next\") " +
|
||||
"to decide whether to fetch the next page"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("PR mirror has the same guarantees", () => {
|
||||
// mirrorGitRepoPullRequestsToGitea has parallel structure:
|
||||
// - pre-fetches existing Gitea "issues that are mirrored PRs",
|
||||
// keyed by `[PR #N]` marker in title
|
||||
// - per-PR callback decides PATCH vs CREATE
|
||||
// - same Gitea-side pagination cap and deadlock-after-commit
|
||||
// risks apply
|
||||
// The fix mirrors gitea-issues here.
|
||||
const prBody = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export async function mirrorGitRepoPullRequestsToGitea\b/
|
||||
);
|
||||
|
||||
test("PR pre-fetch pagination uses Link header", () => {
|
||||
expect(
|
||||
/\bpageIssues\.length\s*<\s*prIssuesPerPage\b/.test(prBody),
|
||||
"the short-page break (pageIssues.length < prIssuesPerPage) must be removed"
|
||||
).toBe(false);
|
||||
// The PR pre-fetch reuses the existingIssuesRes variable name
|
||||
expect(
|
||||
/existingIssuesRes\.headers\.get\(\s*["']link["']\s*\)/.test(prBody) &&
|
||||
/rel="next"/.test(prBody),
|
||||
"the PR pre-fetch loop must use the Link header (rel=\"next\")"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("PR create path defensively rechecks via [PR #N] before httpPost", () => {
|
||||
// Both the enriched and basic-fallback create paths must have
|
||||
// a recheck so partial-commit retries don't duplicate the PR.
|
||||
const rechecks =
|
||||
prBody.match(/Recovered orphan from prior failed attempt for PR/g) ||
|
||||
[];
|
||||
expect(
|
||||
rechecks.length,
|
||||
"expected at least two 'Recovered orphan' log lines " +
|
||||
"(one for the enriched create path, one for the basic-fallback path)"
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test("successful create caches the new issue into the dedup map", () => {
|
||||
// Without this, a retry triggered by a *later* step in the same
|
||||
// per-issue callback (e.g. comment sync throwing) would re-enter
|
||||
// the create path on the next attempt — same root duplication
|
||||
// pattern, different trigger.
|
||||
expect(
|
||||
body.match(
|
||||
/giteaIssueByGitHubNumber\.set\(\s*issue\.number\s*,\s*createdIssue\.data\s*\)/
|
||||
),
|
||||
"after a successful create, the new issue must be stored in giteaIssueByGitHubNumber"
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Regression test for duplicate milestone & label creation on every sync.
|
||||
*
|
||||
* Observed in production (May 2026): a Gitea instance accumulated
|
||||
* 11,847 duplicate closed milestones across 4 mirrored repos after only
|
||||
* a handful of scheduled syncs. Two compounding bugs in
|
||||
* `mirrorGitRepoMilestonesToGitea`:
|
||||
*
|
||||
* (1) The existing-milestones GET to Gitea did NOT pass `state=all`.
|
||||
* Gitea's /milestones endpoint defaults to `state=open`, so the
|
||||
* `existingMilestones` Set never contained any closed milestone
|
||||
* title. Every closed GitHub milestone was misclassified as
|
||||
* missing and re-POSTed on every sync.
|
||||
*
|
||||
* (2) The existing-milestones GET was a single unpaginated call.
|
||||
* Gitea caps response size at `[api].MAX_RESPONSE_ITEMS`
|
||||
* (default 50), so any repo with more milestones than that
|
||||
* silently truncates even when (1) is fixed.
|
||||
*
|
||||
* `mirrorGitRepoLabelsToGitea` has the same pagination bug (2). It
|
||||
* doesn't have bug (1) because /labels has no state filter, and it
|
||||
* hadn't yet shown duplicates in production only because no mirrored
|
||||
* repo had crossed 50 distinct labels — but it would the moment one
|
||||
* did.
|
||||
*
|
||||
* These tests assert on the *structure* of the source rather than
|
||||
* invoking the functions, because behavioral tests for the metadata
|
||||
* pipeline require heavy module mocks that pollute other test files
|
||||
* (bun's mock.module is process-wide). Same convention as
|
||||
* `gitea-issue-dedup-on-retry.test.ts` and
|
||||
* `gitea-mirror-failure-recovery.test.ts`.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(join(import.meta.dir, "gitea.ts"), "utf8");
|
||||
|
||||
/**
|
||||
* Locate the body of a function declaration by name. Walks from the
|
||||
* declaration, balances parens to skip the parameter list (which can
|
||||
* contain destructured object literals with their own braces), then
|
||||
* finds the body's opening brace and its matching close.
|
||||
*/
|
||||
function extractFunctionBody(source: string, declarationStart: RegExp): string {
|
||||
const match = source.match(declarationStart);
|
||||
if (!match) {
|
||||
throw new Error(`Could not locate declaration ${declarationStart}`);
|
||||
}
|
||||
let i = match.index! + match[0].length;
|
||||
while (i < source.length && source[i] !== "(") i++;
|
||||
if (source[i] !== "(") {
|
||||
throw new Error(`No '(' after ${declarationStart}`);
|
||||
}
|
||||
let parenDepth = 0;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "(") parenDepth++;
|
||||
else if (source[i] === ")") {
|
||||
parenDepth--;
|
||||
if (parenDepth === 0) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (i < source.length && source[i] !== "{") i++;
|
||||
if (source[i] !== "{") {
|
||||
throw new Error(`No body '{' for ${declarationStart}`);
|
||||
}
|
||||
let braceDepth = 0;
|
||||
const startIdx = i;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "{") braceDepth++;
|
||||
else if (source[i] === "}") {
|
||||
braceDepth--;
|
||||
if (braceDepth === 0) {
|
||||
return source.slice(startIdx, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unterminated body for ${declarationStart}`);
|
||||
}
|
||||
|
||||
describe("milestone dedup on sync", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export async function mirrorGitRepoMilestonesToGitea\b/
|
||||
);
|
||||
|
||||
test("body contains the per-milestone create branch we expect to guard", () => {
|
||||
// Sanity check: anchor the rest of this suite to the right path.
|
||||
expect(
|
||||
body.includes("existingMilestones"),
|
||||
"expected the existingMilestones set/map used for dedup"
|
||||
).toBe(true);
|
||||
expect(
|
||||
body.match(
|
||||
/await httpPost\(\s*`\$\{config\.giteaConfig\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/milestones`/
|
||||
),
|
||||
"expected the create-milestone httpPost call"
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("existing-milestones GET must include state=all", () => {
|
||||
// Without state=all, Gitea returns only open milestones, so every
|
||||
// closed GitHub milestone is misclassified as missing and re-POSTed
|
||||
// on every sync. This was the root cause of the production blowup.
|
||||
const getMatch = body.match(
|
||||
/httpGet\(\s*`\$\{config\.giteaConfig\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/milestones\?[^`]*`/
|
||||
);
|
||||
expect(
|
||||
getMatch,
|
||||
"expected a milestones httpGet call with a query string"
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
/state=all/.test(getMatch![0]),
|
||||
"the existing-milestones GET must pass state=all (Gitea defaults to state=open)"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("existing-milestones GET must paginate with both Link and X-Total-Count fallback", () => {
|
||||
// Even with state=all, a single unpaginated call only ever sees
|
||||
// the first 50 milestones (Gitea's MAX_RESPONSE_ITEMS cap).
|
||||
//
|
||||
// Gitea's /milestones endpoint does NOT emit a Link header — only
|
||||
// `X-Total-Count`. A strict Link-only check terminates after page 1
|
||||
// and re-POSTs every milestone past index 50 on every sync (the
|
||||
// 9-milestone leak observed on Subnet-Calculator after the
|
||||
// Link-only version of this fix shipped).
|
||||
expect(
|
||||
/milestonesPage\s*\+=\s*1/.test(body),
|
||||
"expected a page-increment loop for existing milestones"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/\.headers\.get\(\s*["']link["']\s*\)/.test(body) &&
|
||||
/rel="next"/.test(body),
|
||||
"the milestones pagination loop must check the Link header (rel=\"next\")"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/\.headers\.get\(\s*["']x-total-count["']\s*\)/.test(body),
|
||||
"the milestones pagination loop must fall back to X-Total-Count when Link header is absent (Gitea /milestones only emits X-Total-Count)"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("newly-created milestone must be cached into existingMilestones", () => {
|
||||
// Defensive: if `milestones` ever contains a same-named entry
|
||||
// twice (unlikely but cheap to guard), we shouldn't POST it twice.
|
||||
expect(
|
||||
/existingMilestones\.add\(\s*milestone\.title\s*\)/.test(body),
|
||||
"after a successful create, the new milestone title must be added to existingMilestones"
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("label dedup on sync", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export async function mirrorGitRepoLabelsToGitea\b/
|
||||
);
|
||||
|
||||
test("body contains the per-label create branch we expect to guard", () => {
|
||||
expect(
|
||||
body.includes("existingLabels"),
|
||||
"expected the existingLabels set used for dedup"
|
||||
).toBe(true);
|
||||
expect(
|
||||
body.match(
|
||||
/await httpPost\(\s*`\$\{config\.giteaConfig\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/labels`/
|
||||
),
|
||||
"expected the create-label httpPost call"
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("existing-labels GET must paginate with both Link and X-Total-Count fallback", () => {
|
||||
// Same Gitea MAX_RESPONSE_ITEMS=50 cap as milestones / issues.
|
||||
// Gitea's /labels endpoint, like /milestones, does NOT emit a Link
|
||||
// header — only `X-Total-Count`. Strict Link-only check would
|
||||
// silently truncate after page 1.
|
||||
expect(
|
||||
/labelsPage\s*\+=\s*1/.test(body),
|
||||
"expected a page-increment loop for existing labels"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/\.headers\.get\(\s*["']link["']\s*\)/.test(body) &&
|
||||
/rel="next"/.test(body),
|
||||
"the labels pagination loop must check the Link header (rel=\"next\")"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/\.headers\.get\(\s*["']x-total-count["']\s*\)/.test(body),
|
||||
"the labels pagination loop must fall back to X-Total-Count when Link header is absent (Gitea /labels only emits X-Total-Count)"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("newly-created label must be cached into existingLabels", () => {
|
||||
expect(
|
||||
/existingLabels\.add\(\s*label\.name\s*\)/.test(body),
|
||||
"after a successful create, the new label name must be added to existingLabels"
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+259
-48
@@ -2146,7 +2146,21 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
if (!pageIssues.length) break;
|
||||
|
||||
existingGiteaIssues.push(...pageIssues);
|
||||
if (pageIssues.length < issuesPerPage) break;
|
||||
|
||||
// Use the Link header (RFC 5988) to decide whether more pages
|
||||
// exist. The old short-page-length heuristic was wrong in both
|
||||
// directions:
|
||||
// - Gitea caps response size at `[api].MAX_RESPONSE_ITEMS`
|
||||
// (default 50), typically lower than `issuesPerPage` (100),
|
||||
// so the very first page already looks "short" and
|
||||
// pagination terminated after 50 items — every issue past
|
||||
// that was misclassified as new and duplicated on every sync.
|
||||
// - For some endpoints Gitea returns the same data on every
|
||||
// page when the page is past the end, so a naive "break on
|
||||
// empty" alone can loop forever if the server doesn't return
|
||||
// []. Link header is the safe signal.
|
||||
const linkHeader = existingIssuesRes.headers.get("link") || "";
|
||||
if (!/\brel="next"/.test(linkHeader)) break;
|
||||
issuesPage += 1;
|
||||
}
|
||||
|
||||
@@ -2289,30 +2303,85 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const createdIssue = await httpPost(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues`,
|
||||
issuePayload,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
targetIssueNumber = createdIssue.data.number;
|
||||
// Defensive recheck before create: a previous retry attempt may
|
||||
// have already created this issue and then thrown. The common
|
||||
// trigger is Gitea's CreateIssue handler committing the issue
|
||||
// insert in one transaction and then deadlocking on the
|
||||
// addLabel / repository counter update in a second transaction.
|
||||
// The issue row is committed and visible, but the in-memory
|
||||
// giteaIssueByGitHubNumber map (built once at function entry)
|
||||
// doesn't know about it, so without this check processWithRetry
|
||||
// would create a duplicate every time the create returns 5xx
|
||||
// after a partial commit.
|
||||
//
|
||||
// Reproduces deterministically on MySQL (Error 1213 / 40001)
|
||||
// and PostgreSQL (40P01); SQLite escapes because writes
|
||||
// serialize globally.
|
||||
let recheckHit: any = null;
|
||||
try {
|
||||
const recheck = await httpGet(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues?state=all&type=issues&q=${encodeURIComponent(`[GH-ISSUE #${issue.number}]`)}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
const candidates = Array.isArray(recheck.data) ? recheck.data : [];
|
||||
recheckHit = candidates.find(
|
||||
(c: any) => extractGitHubIssueNumber(c.title) === issue.number
|
||||
) ?? null;
|
||||
} catch (_recheckErr) {
|
||||
// Best-effort; fall through to create.
|
||||
}
|
||||
|
||||
if (issue.state === "closed" && createdIssue.data.state !== "closed") {
|
||||
try {
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
|
||||
{ state: "closed" },
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
} catch (closeError) {
|
||||
console.error(
|
||||
`[Issues] Failed to close issue #${targetIssueNumber}: ${
|
||||
closeError instanceof Error ? closeError.message : String(closeError)
|
||||
}`
|
||||
);
|
||||
if (recheckHit) {
|
||||
giteaIssueByGitHubNumber.set(issue.number, recheckHit);
|
||||
existingIssue = recheckHit;
|
||||
targetIssueNumber = recheckHit.number;
|
||||
console.log(
|
||||
`[Issues] Recovered orphan from prior failed attempt for #${issue.number}; switching to PATCH`
|
||||
);
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
|
||||
{
|
||||
title: issuePayload.title,
|
||||
body: issuePayload.body,
|
||||
state: issue.state === "closed" ? "closed" : "open",
|
||||
labels: issuePayload.labels,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const createdIssue = await httpPost(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues`,
|
||||
issuePayload,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
targetIssueNumber = createdIssue.data.number;
|
||||
// Cache the new issue immediately so a subsequent retry of
|
||||
// this callback (e.g. triggered by a later step like comment
|
||||
// sync failing) doesn't lose track of it.
|
||||
giteaIssueByGitHubNumber.set(issue.number, createdIssue.data);
|
||||
|
||||
if (issue.state === "closed" && createdIssue.data.state !== "closed") {
|
||||
try {
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
|
||||
{ state: "closed" },
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
} catch (closeError) {
|
||||
console.error(
|
||||
`[Issues] Failed to close issue #${targetIssueNumber}: ${
|
||||
closeError instanceof Error ? closeError.message : String(closeError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2355,7 +2424,13 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
: [];
|
||||
if (!pageComments.length) break;
|
||||
existingComments.push(...pageComments);
|
||||
if (pageComments.length < commentsPerPage) break;
|
||||
// Use the Link header to decide whether more pages exist.
|
||||
// See note on the existing-issues pagination above; the
|
||||
// same Gitea behaviors (MAX_RESPONSE_ITEMS cap and
|
||||
// repeated-data on out-of-bound pages) apply here.
|
||||
const commentsLinkHeader =
|
||||
existingCommentsRes.headers.get("link") || "";
|
||||
if (!/\brel="next"/.test(commentsLinkHeader)) break;
|
||||
commentsPage += 1;
|
||||
}
|
||||
const mirroredCommentIds = new Set<number>();
|
||||
@@ -2983,7 +3058,12 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
}
|
||||
}
|
||||
|
||||
if (pageIssues.length < prIssuesPerPage) break;
|
||||
// See note on the existing-issues pre-fetch above: rely on Link
|
||||
// header (RFC 5988) rather than short-page heuristic. Gitea caps
|
||||
// page size at MAX_RESPONSE_ITEMS (default 50), and some
|
||||
// endpoints repeat data on out-of-bound pages instead of [].
|
||||
const linkHeader = existingIssuesRes.headers.get("link") || "";
|
||||
if (!/\brel="next"/.test(linkHeader)) break;
|
||||
prIssuesPage += 1;
|
||||
}
|
||||
|
||||
@@ -3084,7 +3164,36 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
closed: pr.state === "closed" || pr.merged_at !== null,
|
||||
};
|
||||
|
||||
const existingPrIssue = existingPrIssuesByNumber.get(pr.number);
|
||||
let existingPrIssue = existingPrIssuesByNumber.get(pr.number);
|
||||
// Defensive recheck (see same pattern in mirrorGitRepoIssuesToGitea):
|
||||
// a previous attempt may have committed the PR-issue row and
|
||||
// then thrown on the addLabel/repository-counter update. The
|
||||
// pre-fetched map doesn't know about it, so without this check
|
||||
// processWithRetry would create a duplicate every retry.
|
||||
if (!existingPrIssue) {
|
||||
try {
|
||||
const recheck = await httpGet(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues?state=all&type=issues&q=${encodeURIComponent(`[PR #${pr.number}]`)}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
const candidates = Array.isArray(recheck.data) ? recheck.data : [];
|
||||
const hit = candidates.find((c: any) => {
|
||||
const m = String(c.title || "").match(/\[PR #(\d+)\]/i);
|
||||
return m && Number.parseInt(m[1], 10) === pr.number;
|
||||
});
|
||||
if (hit) {
|
||||
existingPrIssue = hit;
|
||||
existingPrIssuesByNumber.set(pr.number, hit);
|
||||
console.log(
|
||||
`[Pull Requests] Recovered orphan from prior failed attempt for PR #${pr.number}; switching to PATCH`
|
||||
);
|
||||
}
|
||||
} catch (_recheckErr) {
|
||||
// Best-effort; fall through to create.
|
||||
}
|
||||
}
|
||||
if (existingPrIssue) {
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
|
||||
@@ -3145,7 +3254,34 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
};
|
||||
|
||||
try {
|
||||
const existingPrIssue = existingPrIssuesByNumber.get(pr.number);
|
||||
let existingPrIssue = existingPrIssuesByNumber.get(pr.number);
|
||||
// Defensive recheck — same pattern as the enriched create
|
||||
// branch above. Without this, the basic-info fallback would
|
||||
// dup on retry-after-deadlock just like the enriched path.
|
||||
if (!existingPrIssue) {
|
||||
try {
|
||||
const recheck = await httpGet(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues?state=all&type=issues&q=${encodeURIComponent(`[PR #${pr.number}]`)}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
const candidates = Array.isArray(recheck.data) ? recheck.data : [];
|
||||
const hit = candidates.find((c: any) => {
|
||||
const m = String(c.title || "").match(/\[PR #(\d+)\]/i);
|
||||
return m && Number.parseInt(m[1], 10) === pr.number;
|
||||
});
|
||||
if (hit) {
|
||||
existingPrIssue = hit;
|
||||
existingPrIssuesByNumber.set(pr.number, hit);
|
||||
console.log(
|
||||
`[Pull Requests] Recovered orphan from prior failed attempt for PR #${pr.number} (basic fallback); switching to PATCH`
|
||||
);
|
||||
}
|
||||
} catch (_recheckErr) {
|
||||
// Best-effort; fall through to create.
|
||||
}
|
||||
}
|
||||
if (existingPrIssue) {
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
|
||||
@@ -3272,17 +3408,49 @@ export async function mirrorGitRepoLabelsToGitea({
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing labels from Gitea
|
||||
const giteaLabelsRes = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
// Get existing labels from Gitea. Paginate because Gitea caps
|
||||
// response size at `[api].MAX_RESPONSE_ITEMS` (default 50), so a
|
||||
// single unpaginated GET only sees the first 50 labels. Once a repo
|
||||
// crosses that threshold every label past it would be re-POSTed as a
|
||||
// duplicate on every sync.
|
||||
//
|
||||
// Pagination signal: prefer Link header (RFC 5988) when present, but
|
||||
// Gitea's /labels and /milestones endpoints do NOT emit Link headers
|
||||
// — they only emit `X-Total-Count`. Without the fallback, a strict
|
||||
// Link-only check terminated after page 1 and re-POSTed every label
|
||||
// past index 50 on every sync. (Repro found during the milestone
|
||||
// dedup fix: 9 unique milestones leaked past page 1 on a 74-row
|
||||
// /milestones response.)
|
||||
const existingLabels = new Set<string>();
|
||||
const labelsPerPage = 50;
|
||||
let labelsPage = 1;
|
||||
let labelsFetched = 0;
|
||||
while (true) {
|
||||
const giteaLabelsRes = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels?page=${labelsPage}&limit=${labelsPerPage}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
const pageLabels = Array.isArray(giteaLabelsRes.data) ? giteaLabelsRes.data : [];
|
||||
if (!pageLabels.length) break;
|
||||
for (const lbl of pageLabels) existingLabels.add(lbl.name);
|
||||
labelsFetched += pageLabels.length;
|
||||
|
||||
const existingLabels = new Set(
|
||||
giteaLabelsRes.data.map((label: any) => label.name)
|
||||
);
|
||||
const linkHeader = giteaLabelsRes.headers.get("link") || "";
|
||||
if (/\brel="next"/.test(linkHeader)) {
|
||||
labelsPage += 1;
|
||||
continue;
|
||||
}
|
||||
// No Link header (or no rel=next). Fall back to X-Total-Count.
|
||||
const totalStr = giteaLabelsRes.headers.get("x-total-count");
|
||||
const total = totalStr ? Number.parseInt(totalStr, 10) : NaN;
|
||||
if (Number.isFinite(total) && labelsFetched < total) {
|
||||
labelsPage += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let mirroredCount = 0;
|
||||
for (const label of labels) {
|
||||
@@ -3299,6 +3467,9 @@ export async function mirrorGitRepoLabelsToGitea({
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
// Track locally so a duplicate in `labels` (shouldn't happen,
|
||||
// but defensive) doesn't trigger a second POST in the same run.
|
||||
existingLabels.add(label.name);
|
||||
mirroredCount++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -3372,17 +3543,54 @@ export async function mirrorGitRepoMilestonesToGitea({
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing milestones from Gitea
|
||||
const giteaMilestonesRes = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
// Get existing milestones from Gitea. Two correctness requirements:
|
||||
// 1. `state=all` — Gitea's /milestones endpoint defaults to OPEN
|
||||
// only, so without this every CLOSED GitHub milestone is
|
||||
// misclassified as missing and re-POSTed on every sync. This
|
||||
// was the root cause of the 11k+ duplicate-closed-milestone
|
||||
// blowup observed in production.
|
||||
// 2. Pagination via Link header (RFC 5988) — Gitea caps response
|
||||
// size at `[api].MAX_RESPONSE_ITEMS` (default 50), so any repo
|
||||
// with more than ~50 milestones in a given state silently
|
||||
// truncates without it. Same Gitea-side cap that bit the
|
||||
// issues / PRs pre-fetch in commit b76073b.
|
||||
// Pagination signal: prefer Link header (RFC 5988) when present, but
|
||||
// Gitea's /milestones endpoint does NOT emit a Link header — it only
|
||||
// emits `X-Total-Count`. A strict Link-only check terminates after
|
||||
// page 1 and re-POSTs every milestone past index 50 on every sync.
|
||||
// (Repro: post-fix deploy on Subnet-Calculator leaked 9 unique
|
||||
// milestones past page 1 of a 74-row /milestones response.)
|
||||
const existingMilestones = new Set<string>();
|
||||
const milestonesPerPage = 50;
|
||||
let milestonesPage = 1;
|
||||
let milestonesFetched = 0;
|
||||
while (true) {
|
||||
const giteaMilestonesRes = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones?state=all&page=${milestonesPage}&limit=${milestonesPerPage}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
const pageMilestones = Array.isArray(giteaMilestonesRes.data)
|
||||
? giteaMilestonesRes.data
|
||||
: [];
|
||||
if (!pageMilestones.length) break;
|
||||
for (const ms of pageMilestones) existingMilestones.add(ms.title);
|
||||
milestonesFetched += pageMilestones.length;
|
||||
|
||||
const existingMilestones = new Set(
|
||||
giteaMilestonesRes.data.map((milestone: any) => milestone.title)
|
||||
);
|
||||
const linkHeader = giteaMilestonesRes.headers.get("link") || "";
|
||||
if (/\brel="next"/.test(linkHeader)) {
|
||||
milestonesPage += 1;
|
||||
continue;
|
||||
}
|
||||
const totalStr = giteaMilestonesRes.headers.get("x-total-count");
|
||||
const total = totalStr ? Number.parseInt(totalStr, 10) : NaN;
|
||||
if (Number.isFinite(total) && milestonesFetched < total) {
|
||||
milestonesPage += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let mirroredCount = 0;
|
||||
for (const milestone of milestones) {
|
||||
@@ -3400,6 +3608,9 @@ export async function mirrorGitRepoMilestonesToGitea({
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
// Track locally so a duplicate within `milestones` (shouldn't
|
||||
// happen, but defensive) doesn't trigger a second POST.
|
||||
existingMilestones.add(milestone.title);
|
||||
mirroredCount++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
+17
-4
@@ -216,9 +216,21 @@ export async function updateMirrorJobProgress({
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds interrupted jobs that need to be resumed with enhanced criteria
|
||||
* Finds interrupted jobs that need to be resumed with enhanced criteria.
|
||||
*
|
||||
* `logFound` defaults to false because this function is polled from
|
||||
* passive callers (`hasJobsNeedingRecovery` from the health endpoint
|
||||
* and middleware checks). Logging on every poll produces log spam at
|
||||
* one-line-per-poll-per-stuck-job for as long as a job stays stuck.
|
||||
*
|
||||
* Callers that intend to act on the result (i.e. immediately resume
|
||||
* the returned jobs) should pass `logFound: true` so the surfacing
|
||||
* still happens in the recovery flow.
|
||||
*/
|
||||
export async function findInterruptedJobs() {
|
||||
export async function findInterruptedJobs(
|
||||
options: { logFound?: boolean } = {}
|
||||
) {
|
||||
const { logFound = false } = options;
|
||||
try {
|
||||
// Find jobs that are marked as in-progress but haven't been updated recently
|
||||
const cutoffTime = new Date();
|
||||
@@ -243,8 +255,9 @@ export async function findInterruptedJobs() {
|
||||
)
|
||||
);
|
||||
|
||||
// Log details about found jobs for debugging
|
||||
if (interruptedJobs.length > 0) {
|
||||
// Log details about found jobs for debugging — opt-in to avoid
|
||||
// spamming the log when called from periodic passive checks.
|
||||
if (logFound && interruptedJobs.length > 0) {
|
||||
console.log(`Found ${interruptedJobs.length} interrupted jobs:`);
|
||||
interruptedJobs.forEach(job => {
|
||||
const lastCheckpoint = job.lastCheckpoint ? new Date(job.lastCheckpoint).toISOString() : 'never';
|
||||
|
||||
@@ -6,9 +6,22 @@ interface MetadataComponentsState {
|
||||
milestones: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot record of a deleted-branch backup we already took, so the
|
||||
* force-push detector knows to skip the same (branch, giteaSha) pair
|
||||
* next sync. Without this, deleted-on-GitHub branches that linger in
|
||||
* the Gitea mirror trip the detector every cycle and create a new
|
||||
* "Snapshot created" job row forever.
|
||||
*/
|
||||
export interface AcknowledgedDeletion {
|
||||
branch: string;
|
||||
giteaSha: string;
|
||||
}
|
||||
|
||||
export interface RepositoryMetadataState {
|
||||
components: MetadataComponentsState;
|
||||
lastSyncedAt?: string;
|
||||
acknowledgedDeletions: AcknowledgedDeletion[];
|
||||
}
|
||||
|
||||
const defaultComponents: MetadataComponentsState = {
|
||||
@@ -22,6 +35,7 @@ const defaultComponents: MetadataComponentsState = {
|
||||
export function createDefaultMetadataState(): RepositoryMetadataState {
|
||||
return {
|
||||
components: { ...defaultComponents },
|
||||
acknowledgedDeletions: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +79,20 @@ export function parseRepositoryMetadataState(
|
||||
base.lastSyncedAt = parsed.lastMetadataSync;
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.acknowledgedDeletions)) {
|
||||
base.acknowledgedDeletions = parsed.acknowledgedDeletions.flatMap(
|
||||
(entry: unknown): AcknowledgedDeletion[] => {
|
||||
if (!entry || typeof entry !== "object") return [];
|
||||
const branch = (entry as { branch?: unknown }).branch;
|
||||
const giteaSha = (entry as { giteaSha?: unknown }).giteaSha;
|
||||
if (typeof branch !== "string" || typeof giteaSha !== "string") {
|
||||
return [];
|
||||
}
|
||||
return [{ branch, giteaSha }];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Regression test for the "interrupted jobs never resume after
|
||||
* startup" orchestration bug.
|
||||
*
|
||||
* Symptom (before this fix):
|
||||
* - Server starts cleanly. Middleware runs initial recovery pass,
|
||||
* finds no interrupted jobs, sets `recoveryAttempted = true` and
|
||||
* `recoveryInitialized = true`.
|
||||
* - User triggers a sync at T=N (well after startup). The sync
|
||||
* creates a `mirrorJobs` row with `inProgress=true`.
|
||||
* - The sync fails mid-flight (deadlock retry, network blip,
|
||||
* container restart of an upstream service, etc.) and never
|
||||
* reaches the resume codepath, so the row stays
|
||||
* `inProgress=true` with no checkpoint.
|
||||
* - `findInterruptedJobs` (called periodically from the health
|
||||
* endpoint via `hasJobsNeedingRecovery`) detects it and logs
|
||||
* `Found 1 interrupted jobs:` on every poll.
|
||||
* - But the resumer (`resumeInterruptedJob`) is only invoked from
|
||||
* `initializeRecovery`, which is gated behind the
|
||||
* once-per-process `!recoveryAttempted` check in
|
||||
* `src/middleware.ts`. That check is false after startup, so the
|
||||
* resumer NEVER fires again. The job is stuck forever.
|
||||
*
|
||||
* Root cause: the middleware gate was symmetric — "skip recovery if
|
||||
* we've ever attempted it" — but it should have been "always
|
||||
* re-evaluate; only the recovery routine's own 5-minute throttle
|
||||
* (`skipIfRecentAttempt` inside `initializeRecovery`) prevents
|
||||
* thrashing".
|
||||
*
|
||||
* Secondary issue: `findInterruptedJobs` logged unconditionally on
|
||||
* every call, even from passive checks like `hasJobsNeedingRecovery`,
|
||||
* producing log spam at one line per poll per stuck job.
|
||||
*
|
||||
* This test asserts on the *structure* of the source rather than
|
||||
* invoking the middleware, because exercising the middleware path
|
||||
* requires a full Astro request pipeline with heavy mocks. See
|
||||
* `gitea-mirror-failure-recovery.test.ts` and
|
||||
* `gitea-issue-dedup-on-retry.test.ts` for the same convention.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const MIDDLEWARE_SRC = readFileSync(
|
||||
join(import.meta.dir, "../middleware.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const HELPERS_SRC = readFileSync(
|
||||
join(import.meta.dir, "helpers.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const RECOVERY_SRC = readFileSync(
|
||||
join(import.meta.dir, "recovery.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
describe("orchestrator: resume interrupted jobs after startup", () => {
|
||||
test("middleware no longer gates recovery behind once-per-process `recoveryAttempted`", () => {
|
||||
// The old gate looked like:
|
||||
// if (!recoveryInitialized && !recoveryAttempted) {
|
||||
// recoveryAttempted = true;
|
||||
// ...
|
||||
// }
|
||||
// Once both flags flipped on the first request, recovery never
|
||||
// ran again — even if jobs got stuck mid-runtime.
|
||||
expect(
|
||||
/\brecoveryAttempted\b/.test(MIDDLEWARE_SRC),
|
||||
"the `recoveryAttempted` once-per-process flag must be removed " +
|
||||
"from middleware.ts so post-startup interruptions can recover"
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("middleware uses an in-flight latch (not a one-shot gate) for runtime safety", () => {
|
||||
// The replacement uses `recoveryInFlight` as a per-request
|
||||
// mutex — set true at the start, set false in `finally`. The
|
||||
// actual throttle (5-minute "recent attempt") lives inside
|
||||
// `initializeRecovery()` in recovery.ts, which is the right
|
||||
// place for it.
|
||||
expect(
|
||||
/\brecoveryInFlight\b/.test(MIDDLEWARE_SRC),
|
||||
"middleware should use `recoveryInFlight` as the in-flight latch"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/recoveryInFlight\s*=\s*false/.test(MIDDLEWARE_SRC) &&
|
||||
/\bfinally\s*\{[\s\S]*?recoveryInFlight\s*=\s*false[\s\S]*?\}/.test(
|
||||
MIDDLEWARE_SRC
|
||||
),
|
||||
"the in-flight latch must be released in a `finally` block " +
|
||||
"so an exception during recovery doesn't permanently jam the latch"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("findInterruptedJobs logging is opt-in (default off) to stop poll spam", () => {
|
||||
// Active recovery callers (initializeRecovery) opt in by passing
|
||||
// { logFound: true }; passive checks (hasJobsNeedingRecovery,
|
||||
// health endpoint, etc.) default to silent.
|
||||
expect(
|
||||
/export async function findInterruptedJobs\(\s*options[^)]*\)/.test(
|
||||
HELPERS_SRC
|
||||
),
|
||||
"findInterruptedJobs should accept an options object"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/logFound\s*=\s*false/.test(HELPERS_SRC),
|
||||
"the `logFound` option should default to false " +
|
||||
"so periodic passive checks (e.g. hasJobsNeedingRecovery) " +
|
||||
"don't spam the log on every poll"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/if\s*\(\s*logFound\s*&&\s*interruptedJobs\.length\s*>\s*0\s*\)/.test(
|
||||
HELPERS_SRC
|
||||
),
|
||||
"the `Found N interrupted jobs` log must be gated by `logFound`"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("active recovery path opts in to per-job logging", () => {
|
||||
// Without this, the actual recovery cycle would also be silent
|
||||
// — operators need to see which jobs are being resumed.
|
||||
expect(
|
||||
/findInterruptedJobs\(\s*\{\s*logFound:\s*true\s*\}\s*\)/.test(
|
||||
RECOVERY_SRC
|
||||
),
|
||||
"initializeRecovery() must pass { logFound: true } to findInterruptedJobs " +
|
||||
"so the active recovery cycle still logs which jobs it's working on"
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -121,8 +121,9 @@ export async function initializeRecovery(options: {
|
||||
// Clean up stale jobs first
|
||||
await cleanupStaleJobs();
|
||||
|
||||
// Find interrupted jobs
|
||||
const interruptedJobs = await findInterruptedJobs();
|
||||
// Find interrupted jobs (with per-job logging — this is the
|
||||
// active recovery path that will immediately try to resume them)
|
||||
const interruptedJobs = await findInterruptedJobs({ logFound: true });
|
||||
|
||||
if (interruptedJobs.length === 0) {
|
||||
console.log('No interrupted jobs found.');
|
||||
|
||||
@@ -316,4 +316,189 @@ describe("detectForcePush", () => {
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.skipReason).toContain("Failed to fetch GitHub branches");
|
||||
});
|
||||
|
||||
// --- acknowledgedDeletions: suppress already-handled deleted branches ---
|
||||
//
|
||||
// Production reproduction (Simple-WP-Helpdesk, May 2026): a branch
|
||||
// deleted on GitHub remained in the Gitea mirror because gitea-mirror
|
||||
// is one-way. Every 4h sync re-detected it as "deleted" and inserted
|
||||
// a fresh "Snapshot created" job row — 7 zombies accumulated in 24h.
|
||||
// Fix: caller threads in the list of (branch, giteaSha) pairs already
|
||||
// backed up; detector suppresses matching entries.
|
||||
|
||||
it("suppresses a deleted branch when acknowledged at the same giteaSha", async () => {
|
||||
const deps = makeDeps({
|
||||
giteaBranches: [
|
||||
{ name: "main", sha: "aaa" },
|
||||
{ name: "fix/abandoned", sha: "bbb" },
|
||||
],
|
||||
githubBranches: [{ name: "main", sha: "aaa" }],
|
||||
});
|
||||
|
||||
const result = await detectForcePush({
|
||||
...baseArgs,
|
||||
octokit: dummyOctokit,
|
||||
acknowledgedDeletions: [{ branch: "fix/abandoned", giteaSha: "bbb" }],
|
||||
_deps: deps,
|
||||
});
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
expect(result.affectedBranches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("re-flags a previously-acknowledged branch if its giteaSha changed", async () => {
|
||||
// Edge case: a deleted branch was restored (Gitea picked up the
|
||||
// new history), then re-deleted. Same name but different giteaSha
|
||||
// means the acknowledged entry doesn't match — back up the new
|
||||
// state.
|
||||
const deps = makeDeps({
|
||||
giteaBranches: [
|
||||
{ name: "main", sha: "aaa" },
|
||||
{ name: "fix/abandoned", sha: "ccc" },
|
||||
],
|
||||
githubBranches: [{ name: "main", sha: "aaa" }],
|
||||
});
|
||||
|
||||
const result = await detectForcePush({
|
||||
...baseArgs,
|
||||
octokit: dummyOctokit,
|
||||
acknowledgedDeletions: [{ branch: "fix/abandoned", giteaSha: "bbb" }], // stale SHA
|
||||
_deps: deps,
|
||||
});
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.affectedBranches).toHaveLength(1);
|
||||
expect(result.affectedBranches[0]).toMatchObject({
|
||||
name: "fix/abandoned",
|
||||
reason: "deleted",
|
||||
giteaSha: "ccc",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses only the acknowledged deletion when multiple deletions exist", async () => {
|
||||
const deps = makeDeps({
|
||||
giteaBranches: [
|
||||
{ name: "main", sha: "aaa" },
|
||||
{ name: "fix/old", sha: "bbb" },
|
||||
{ name: "fix/new", sha: "ccc" },
|
||||
],
|
||||
githubBranches: [{ name: "main", sha: "aaa" }],
|
||||
});
|
||||
|
||||
const result = await detectForcePush({
|
||||
...baseArgs,
|
||||
octokit: dummyOctokit,
|
||||
acknowledgedDeletions: [{ branch: "fix/old", giteaSha: "bbb" }],
|
||||
_deps: deps,
|
||||
});
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.affectedBranches).toHaveLength(1);
|
||||
expect(result.affectedBranches[0]?.name).toBe("fix/new");
|
||||
});
|
||||
|
||||
it("treats undefined acknowledgedDeletions as empty (back-compat with callers that don't pass it)", async () => {
|
||||
const deps = makeDeps({
|
||||
giteaBranches: [
|
||||
{ name: "main", sha: "aaa" },
|
||||
{ name: "fix/abandoned", sha: "bbb" },
|
||||
],
|
||||
githubBranches: [{ name: "main", sha: "aaa" }],
|
||||
});
|
||||
|
||||
const result = await detectForcePush({
|
||||
...baseArgs,
|
||||
octokit: dummyOctokit,
|
||||
// acknowledgedDeletions omitted
|
||||
_deps: deps,
|
||||
});
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.affectedBranches[0]?.reason).toBe("deleted");
|
||||
});
|
||||
|
||||
it("does not suppress diverged branches via the acknowledgedDeletions list", async () => {
|
||||
// The suppression list is specifically for `reason: "deleted"`.
|
||||
// A divergence at the same name + matching old giteaSha (an
|
||||
// impossible-in-practice combination, but be explicit about the
|
||||
// boundary) must still report.
|
||||
const deps = makeDeps({
|
||||
giteaBranches: [{ name: "main", sha: "aaa" }],
|
||||
githubBranches: [{ name: "main", sha: "rewritten" }],
|
||||
ancestryResult: false,
|
||||
});
|
||||
|
||||
const result = await detectForcePush({
|
||||
...baseArgs,
|
||||
octokit: dummyOctokit,
|
||||
acknowledgedDeletions: [{ branch: "main", giteaSha: "aaa" }],
|
||||
_deps: deps,
|
||||
});
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.affectedBranches[0]?.reason).toBe("diverged");
|
||||
});
|
||||
});
|
||||
|
||||
// --- metadata-state round-trip for the new acknowledgedDeletions field ---
|
||||
|
||||
describe("metadata-state acknowledgedDeletions persistence", () => {
|
||||
it("parse → mutate → serialize → parse round-trips entries cleanly", async () => {
|
||||
const {
|
||||
parseRepositoryMetadataState,
|
||||
serializeRepositoryMetadataState,
|
||||
createDefaultMetadataState,
|
||||
} = await import("../metadata-state");
|
||||
|
||||
const state = createDefaultMetadataState();
|
||||
state.acknowledgedDeletions.push(
|
||||
{ branch: "fix/abandoned", giteaSha: "bbb" },
|
||||
{ branch: "fix/other", giteaSha: "ccc" },
|
||||
);
|
||||
|
||||
const reparsed = parseRepositoryMetadataState(
|
||||
serializeRepositoryMetadataState(state),
|
||||
);
|
||||
|
||||
expect(reparsed.acknowledgedDeletions).toEqual([
|
||||
{ branch: "fix/abandoned", giteaSha: "bbb" },
|
||||
{ branch: "fix/other", giteaSha: "ccc" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults acknowledgedDeletions to [] for legacy metadata blobs", async () => {
|
||||
const { parseRepositoryMetadataState } = await import("../metadata-state");
|
||||
|
||||
// Metadata that predates this field — no acknowledgedDeletions key
|
||||
const legacy = JSON.stringify({
|
||||
components: {
|
||||
releases: true,
|
||||
issues: false,
|
||||
pullRequests: false,
|
||||
labels: false,
|
||||
milestones: false,
|
||||
},
|
||||
lastSyncedAt: "2026-05-01T00:00:00Z",
|
||||
});
|
||||
|
||||
expect(parseRepositoryMetadataState(legacy).acknowledgedDeletions).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops malformed acknowledged entries without throwing", async () => {
|
||||
const { parseRepositoryMetadataState } = await import("../metadata-state");
|
||||
|
||||
const malformed = JSON.stringify({
|
||||
components: {},
|
||||
acknowledgedDeletions: [
|
||||
{ branch: "good", giteaSha: "abc" },
|
||||
{ branch: 42, giteaSha: "abc" }, // bad type
|
||||
null,
|
||||
{ branch: "missing-sha" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(parseRepositoryMetadataState(malformed).acknowledgedDeletions).toEqual([
|
||||
{ branch: "good", giteaSha: "abc" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { httpGet, HttpError } from "@/lib/http-client";
|
||||
import type { AcknowledgedDeletion } from "@/lib/metadata-state";
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -172,6 +173,7 @@ export async function detectForcePush({
|
||||
octokit,
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
acknowledgedDeletions,
|
||||
_deps,
|
||||
}: {
|
||||
giteaUrl: string;
|
||||
@@ -181,6 +183,18 @@ export async function detectForcePush({
|
||||
octokit: Octokit;
|
||||
githubOwner: string;
|
||||
githubRepo: string;
|
||||
/**
|
||||
* Deleted-branch backups we already took. A Gitea branch missing
|
||||
* from GitHub is suppressed from `affectedBranches` when its current
|
||||
* giteaSha matches an entry here. Without this, deleted branches
|
||||
* trip detection every sync because gitea-mirror is one-way:
|
||||
* deletions never propagate to the Gitea mirror, so the "branch in
|
||||
* Gitea, gone from GitHub" condition holds forever and we'd take a
|
||||
* fresh snapshot on every cycle.
|
||||
*
|
||||
* Stored on the repository row via RepositoryMetadataState.
|
||||
*/
|
||||
acknowledgedDeletions?: readonly AcknowledgedDeletion[];
|
||||
/** @internal — test-only dependency injection */
|
||||
_deps?: {
|
||||
fetchGiteaBranches: typeof fetchGiteaBranches;
|
||||
@@ -189,6 +203,9 @@ export async function detectForcePush({
|
||||
};
|
||||
}): Promise<ForcePushDetectionResult> {
|
||||
const deps = _deps ?? { fetchGiteaBranches, fetchGitHubBranches, checkAncestry };
|
||||
const acknowledged = new Set(
|
||||
(acknowledgedDeletions ?? []).map((entry) => `${entry.branch}@${entry.giteaSha}`),
|
||||
);
|
||||
|
||||
// 1. Fetch Gitea branches
|
||||
let giteaBranches: BranchInfo[];
|
||||
@@ -237,7 +254,16 @@ export async function detectForcePush({
|
||||
const githubSha = githubBranchMap.get(giteaBranch.name);
|
||||
|
||||
if (githubSha === undefined) {
|
||||
// Branch was deleted on GitHub
|
||||
// Branch was deleted on GitHub. Suppress if we already took a
|
||||
// snapshot at this exact giteaSha — the deletion is permanent
|
||||
// on the GitHub side but the branch lingers in the Gitea
|
||||
// mirror, so without this check the detector trips every sync.
|
||||
// If the giteaSha later changes (branch restored, then deleted
|
||||
// again with new history), the entry won't match and we'll
|
||||
// back up the new state.
|
||||
if (acknowledged.has(`${giteaBranch.name}@${giteaBranch.sha}`)) {
|
||||
continue;
|
||||
}
|
||||
affected.push({
|
||||
name: giteaBranch.name,
|
||||
reason: "deleted",
|
||||
|
||||
+69
-36
@@ -6,7 +6,8 @@ import { startRepositoryCleanupService, stopRepositoryCleanupService } from './l
|
||||
import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager';
|
||||
import { setupSignalHandlers } from './lib/signal-handlers';
|
||||
import { auth } from './lib/auth';
|
||||
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
|
||||
import { isHeaderAuthEnabled } from './lib/auth-header';
|
||||
import { mintSessionFromHeaders } from './lib/auth-header-bridge';
|
||||
import { initializeConfigFromEnv } from './lib/env-config-loader';
|
||||
import { db, users } from './lib/db';
|
||||
import { getBasePath } from './lib/base-path';
|
||||
@@ -17,9 +18,16 @@ function prefixAstroInternalAssetPaths(html: string, basePath: string): string {
|
||||
return html.replace(ASTRO_INTERNAL_ASSET_PATH_PATTERN, `$1${basePath}/$2`);
|
||||
}
|
||||
|
||||
// Flag to track if recovery has been initialized
|
||||
// Flag to track whether the *startup* recovery pass has run. This
|
||||
// only gates the post-startup chain (cleanup service, scheduler,
|
||||
// etc.) and the "startup script may not have run" log line — it does
|
||||
// NOT gate subsequent recovery attempts, see below.
|
||||
let recoveryInitialized = false;
|
||||
let recoveryAttempted = false;
|
||||
// Throttle for runtime recovery retries (separate from the
|
||||
// initializeRecovery() 5-minute throttle inside recovery.ts, which is
|
||||
// keyed on `lastRecoveryAttempt`). This prevents one middleware
|
||||
// invocation from triggering recovery while another is in flight.
|
||||
let recoveryInFlight = false;
|
||||
let cleanupServiceStarted = false;
|
||||
let schedulerServiceStarted = false;
|
||||
let repositoryCleanupServiceStarted = false;
|
||||
@@ -30,6 +38,13 @@ let envConfigCheckCount = 0; // Track attempts to avoid excessive checking
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const basePath = getBasePath();
|
||||
|
||||
// Set-Cookie headers we mint during the header-auth bridge below.
|
||||
// Forwarded onto the outbound response after `next()` so the browser
|
||||
// persists the Better Auth session cookie. Until that happens the
|
||||
// SPA's /api/auth/get-session call returns null and bounces to
|
||||
// /login — see the bridge block for the full rationale.
|
||||
let pendingSetCookies: string[] = [];
|
||||
|
||||
// First, try Better Auth session (cookie-based)
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -39,36 +54,26 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
if (session) {
|
||||
context.locals.user = session.user;
|
||||
context.locals.session = session.session;
|
||||
} else {
|
||||
// No cookie session, check for header authentication
|
||||
if (isHeaderAuthEnabled()) {
|
||||
const headerUser = await authenticateWithHeaders(context.request.headers);
|
||||
if (headerUser) {
|
||||
// Create a session-like object for header auth
|
||||
context.locals.user = {
|
||||
id: headerUser.id,
|
||||
email: headerUser.email,
|
||||
emailVerified: headerUser.emailVerified,
|
||||
name: headerUser.name || headerUser.username,
|
||||
username: headerUser.username,
|
||||
createdAt: headerUser.createdAt,
|
||||
updatedAt: headerUser.updatedAt,
|
||||
};
|
||||
context.locals.session = {
|
||||
id: `header-${headerUser.id}`,
|
||||
userId: headerUser.id,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1 day
|
||||
ipAddress: context.request.headers.get('x-forwarded-for') || context.clientAddress,
|
||||
userAgent: context.request.headers.get('user-agent'),
|
||||
};
|
||||
} else {
|
||||
context.locals.user = null;
|
||||
context.locals.session = null;
|
||||
}
|
||||
} else if (isHeaderAuthEnabled()) {
|
||||
// No cookie session, but header auth is on. Call the
|
||||
// header-auth plugin endpoint to mint a real Better Auth
|
||||
// session from the trusted upstream headers, then forward the
|
||||
// Set-Cookie onto the outbound response so the SPA's next
|
||||
// /api/auth/get-session call carries the cookie. Without this
|
||||
// bridge the React app sees null on mount and redirects to
|
||||
// /login even though server-rendered code paths know the user.
|
||||
const bridge = await mintSessionFromHeaders(context.request);
|
||||
if (bridge) {
|
||||
context.locals.user = bridge.user;
|
||||
context.locals.session = bridge.session;
|
||||
pendingSetCookies = bridge.setCookies;
|
||||
} else {
|
||||
context.locals.user = null;
|
||||
context.locals.session = null;
|
||||
}
|
||||
} else {
|
||||
context.locals.user = null;
|
||||
context.locals.session = null;
|
||||
}
|
||||
} catch (error) {
|
||||
// If there's an error getting the session, set to null
|
||||
@@ -118,17 +123,30 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize recovery system only once when the server starts
|
||||
// This is a fallback in case the startup script didn't run
|
||||
if (!recoveryInitialized && !recoveryAttempted) {
|
||||
recoveryAttempted = true;
|
||||
// Run recovery if jobs need it.
|
||||
//
|
||||
// The previous implementation used a once-per-process gate, so
|
||||
// any mid-runtime interruption (a sync that started after boot,
|
||||
// crashed mid-flight, and never got back to the resume path)
|
||||
// would sit at `in_progress=true` forever — the periodic detector
|
||||
// kept finding it, but the resumer never re-fired. This block
|
||||
// now re-evaluates on every request, gated by `recoveryInFlight`
|
||||
// (per-process) plus the 5-minute throttle inside
|
||||
// `initializeRecovery()` (which prevents thrashing if a resume
|
||||
// cycle keeps failing).
|
||||
if (!recoveryInFlight) {
|
||||
recoveryInFlight = true;
|
||||
|
||||
try {
|
||||
// Check if recovery is actually needed before attempting
|
||||
const needsRecovery = await hasJobsNeedingRecovery();
|
||||
|
||||
if (needsRecovery) {
|
||||
console.log('⚠️ Middleware detected jobs needing recovery (startup script may not have run)');
|
||||
if (!recoveryInitialized) {
|
||||
console.log('⚠️ Middleware detected jobs needing recovery (startup script may not have run)');
|
||||
} else {
|
||||
console.log('⚠️ Middleware detected jobs needing recovery mid-run (sync interrupted after startup)');
|
||||
}
|
||||
console.log('Attempting recovery from middleware...');
|
||||
|
||||
// Run recovery with a shorter timeout since this is during request handling
|
||||
@@ -148,7 +166,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
} else {
|
||||
console.log('⚠️ Middleware recovery completed with some issues');
|
||||
}
|
||||
} else {
|
||||
} else if (!recoveryInitialized) {
|
||||
// Only log this on the first request; otherwise we'd spam
|
||||
// it on every request.
|
||||
console.log('✅ No recovery needed (startup script likely handled it)');
|
||||
}
|
||||
|
||||
@@ -161,7 +181,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const status = getRecoveryStatus();
|
||||
console.log('Recovery status:', status);
|
||||
|
||||
recoveryInitialized = true; // Mark as attempted to avoid retries
|
||||
recoveryInitialized = true;
|
||||
} finally {
|
||||
recoveryInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +250,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// Continue with the request
|
||||
const response = await next();
|
||||
|
||||
// Forward any Set-Cookie headers minted by the header-auth bridge
|
||||
// onto the outbound response. Done before the early returns below so
|
||||
// every return path (basePath rewrite, non-HTML responses, etc.)
|
||||
// carries the cookie. The body-rewrite branch further down clones
|
||||
// `response.headers`, so anything appended here survives the clone.
|
||||
if (pendingSetCookies.length > 0) {
|
||||
for (const cookie of pendingSetCookies) {
|
||||
response.headers.append("set-cookie", cookie);
|
||||
}
|
||||
}
|
||||
|
||||
if (basePath === "/") {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -47,8 +47,11 @@ export async function POST(context: APIContext) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Better Auth server API to register OAuth2 application
|
||||
const response = await auth.api.registerOAuthApplication({
|
||||
// Use Better Auth server API to register OAuth2 client (RFC 7591
|
||||
// dynamic client registration via @better-auth/oauth-provider).
|
||||
// Note: jwks / jwks_uri / metadata are not part of the new
|
||||
// registration body and are intentionally omitted.
|
||||
const response = await auth.api.registerOAuthClient({
|
||||
body: {
|
||||
client_name,
|
||||
redirect_uris,
|
||||
@@ -61,9 +64,6 @@ export async function POST(context: APIContext) {
|
||||
contacts,
|
||||
tos_uri,
|
||||
policy_uri,
|
||||
jwks_uri,
|
||||
jwks,
|
||||
metadata,
|
||||
software_id,
|
||||
software_version,
|
||||
software_statement,
|
||||
|
||||
@@ -152,25 +152,14 @@ export async function POST(context: APIContext) {
|
||||
headers.set("cookie", cookieHeader);
|
||||
}
|
||||
|
||||
// Register the SSO provider using Better Auth's API
|
||||
const response = await auth.api.registerSSOProvider({
|
||||
// Register the SSO provider using Better Auth's API.
|
||||
// auth.api.* returns the parsed result directly (not a fetch Response); it
|
||||
// throws APIError on failure, which createSecureErrorResponse handles below.
|
||||
const result = await auth.api.registerSSOProvider({
|
||||
body: registrationBody,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Failed to register SSO provider: ${error}` }),
|
||||
{
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Mirror provider entry into local SSO table for UI listing
|
||||
try {
|
||||
const existing = await db
|
||||
@@ -183,6 +172,13 @@ export async function POST(context: APIContext) {
|
||||
issuer: registrationBody.issuer,
|
||||
domain: registrationBody.domain,
|
||||
organizationId: registrationBody.organizationId,
|
||||
// Mark this provider as trusted for the `domain` it was registered
|
||||
// under. Better Auth's SSO plugin gates account auto-linking on this
|
||||
// flag together with an email-domain match (validateEmailDomain), so
|
||||
// sign-ins from users outside the registered domain are NOT
|
||||
// auto-linked even though the provider is trusted. The plugin's own
|
||||
// create call hardcodes this to false, so we set it here.
|
||||
domainVerified: true,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -210,6 +206,7 @@ export async function POST(context: APIContext) {
|
||||
userId: user.id,
|
||||
providerId: registrationBody.providerId,
|
||||
organizationId: registrationBody.organizationId,
|
||||
domainVerified: true,
|
||||
});
|
||||
}
|
||||
} catch (mirroringError) {
|
||||
|
||||
@@ -1,26 +1,61 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuth } from "@/lib/utils/auth-helpers";
|
||||
import { db, oauthApplications } from "@/lib/db";
|
||||
import { nanoid } from "nanoid";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, oauthClients } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { generateRandomString } from "@/lib/utils";
|
||||
|
||||
// GET /api/sso/applications - List all OAuth applications
|
||||
// Backward-compatible OAuth application management API.
|
||||
//
|
||||
// Migrated from the deprecated `oidc-provider` plugin to
|
||||
// `@better-auth/oauth-provider`. Clients now live in the `oauth_clients`
|
||||
// table and are managed by the plugin (which generates and hashes the
|
||||
// client secret), so mutations delegate to `auth.api.*OAuthClient`. Reads
|
||||
// are served straight from the table and mapped back to the legacy response
|
||||
// shape (`redirectURLs` as a comma-separated string) so existing consumers
|
||||
// — notably the consent page — keep working.
|
||||
|
||||
// `redirectUris` is stored by the adapter as a JSON-encoded string[].
|
||||
function parseRedirectUris(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value as string[];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
// Legacy comma-separated fallback
|
||||
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Map a new oauth_clients row onto the legacy application response shape.
|
||||
function toLegacyApplication(row: typeof oauthClients.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
clientId: row.clientId,
|
||||
name: row.name ?? "",
|
||||
redirectURLs: parseRedirectUris(row.redirectUris).join(","),
|
||||
type: row.type ?? "web",
|
||||
disabled: row.disabled ?? false,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
// Never expose the (hashed) client secret in list responses
|
||||
clientSecret: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/sso/applications - List all OAuth clients
|
||||
export async function GET(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const applications = await db.select().from(oauthApplications);
|
||||
const rows = await db.select().from(oauthClients);
|
||||
const sanitized = rows.map(toLegacyApplication);
|
||||
|
||||
// Don't send client secrets in list response
|
||||
const sanitizedApps = applications.map(app => ({
|
||||
...app,
|
||||
clientSecret: undefined,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify(sanitizedApps), {
|
||||
return new Response(JSON.stringify(sanitized), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -29,10 +64,10 @@ export async function GET(context: APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/sso/applications - Create a new OAuth application
|
||||
// POST /api/sso/applications - Register a new OAuth client
|
||||
export async function POST(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const body = await context.request.json();
|
||||
@@ -49,27 +84,27 @@ export async function POST(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Generate client credentials
|
||||
const clientId = `client_${generateRandomString(32)}`;
|
||||
const clientSecret = `secret_${generateRandomString(48)}`;
|
||||
const redirect_uris = Array.isArray(redirectURLs)
|
||||
? redirectURLs
|
||||
: String(redirectURLs)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Insert new application
|
||||
const [newApp] = await db
|
||||
.insert(oauthApplications)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
clientId,
|
||||
clientSecret,
|
||||
name,
|
||||
redirectURLs: Array.isArray(redirectURLs) ? redirectURLs.join(",") : redirectURLs,
|
||||
// Delegate to the OAuth provider plugin so the client_id / client_secret
|
||||
// are generated and the secret is stored using the plugin's hashing.
|
||||
const created = await auth.api.createOAuthClient({
|
||||
headers: context.request.headers,
|
||||
body: {
|
||||
client_name: name,
|
||||
redirect_uris,
|
||||
type,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
userId: user.id,
|
||||
disabled: false,
|
||||
})
|
||||
.returning();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(newApp), {
|
||||
// The plugin returns RFC-style snake_case fields. Surface them plus the
|
||||
// one-time client_secret (only returned here, never again).
|
||||
return new Response(JSON.stringify(created), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -78,18 +113,19 @@ export async function POST(context: APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/sso/applications/:id - Update an OAuth application
|
||||
// PUT /api/sso/applications?id=<clientId> - Update an OAuth client
|
||||
export async function PUT(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const url = new URL(context.request.url);
|
||||
const appId = url.pathname.split("/").pop();
|
||||
// Accept the OAuth client_id either from the query string or the path.
|
||||
const clientId = url.searchParams.get("id") || url.pathname.split("/").pop();
|
||||
|
||||
if (!appId) {
|
||||
if (!clientId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Application ID is required" }),
|
||||
JSON.stringify({ error: "Client ID is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -98,35 +134,26 @@ export async function PUT(context: APIContext) {
|
||||
}
|
||||
|
||||
const body = await context.request.json();
|
||||
const { name, redirectURLs, disabled, metadata } = body;
|
||||
const { name, redirectURLs, disabled } = body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
const updateBody: Record<string, unknown> = { client_id: clientId };
|
||||
if (name !== undefined) updateBody.client_name = name;
|
||||
if (disabled !== undefined) updateBody.disabled = disabled;
|
||||
if (redirectURLs !== undefined) {
|
||||
updateData.redirectURLs = Array.isArray(redirectURLs)
|
||||
? redirectURLs.join(",")
|
||||
: redirectURLs;
|
||||
}
|
||||
if (disabled !== undefined) updateData.disabled = disabled;
|
||||
if (metadata !== undefined) updateData.metadata = JSON.stringify(metadata);
|
||||
|
||||
const [updated] = await db
|
||||
.update(oauthApplications)
|
||||
.set({
|
||||
...updateData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthApplications.id, appId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return new Response(JSON.stringify({ error: "Application not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
updateBody.redirect_uris = Array.isArray(redirectURLs)
|
||||
? redirectURLs
|
||||
: String(redirectURLs)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ...updated, clientSecret: undefined }), {
|
||||
const updated = await auth.api.updateOAuthClient({
|
||||
headers: context.request.headers,
|
||||
body: updateBody as any,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -135,18 +162,18 @@ export async function PUT(context: APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sso/applications/:id - Delete an OAuth application
|
||||
// DELETE /api/sso/applications?id=<clientId> - Delete an OAuth client
|
||||
export async function DELETE(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const url = new URL(context.request.url);
|
||||
const appId = url.searchParams.get("id");
|
||||
const clientId = url.searchParams.get("id");
|
||||
|
||||
if (!appId) {
|
||||
if (!clientId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Application ID is required" }),
|
||||
JSON.stringify({ error: "Client ID is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -154,18 +181,26 @@ export async function DELETE(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = await db
|
||||
.delete(oauthApplications)
|
||||
.where(eq(oauthApplications.id, appId))
|
||||
.returning();
|
||||
// Ensure the client exists so we can return a 404 (the plugin endpoint
|
||||
// may otherwise succeed silently).
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(oauthClients)
|
||||
.where(eq(oauthClients.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (deleted.length === 0) {
|
||||
if (existing.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "Application not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
await auth.api.deleteOAuthClient({
|
||||
headers: context.request.headers,
|
||||
body: { client_id: clientId },
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -173,4 +208,4 @@ export async function DELETE(context: APIContext) {
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "SSO applications API");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user