feat(enterprise): add SAML 2.0, SCIM 2.0, OCI Registry, NPM package engine & Docker Hub release workflow
swadhinbiswas committed 2 months ago
0257552
1 parent 9219247
commit 0257552
25 files changed
+1880 -193
Showing 25 changed files+1880-193
.github/workflows/docker-publish.ymlDOCKERHUB.mddoc/docs-parity-baseline.jsondocker-entrypoint.shscripts/worker.tssrc/db/index.tssrc/lib/cross-repo-issues.tssrc/lib/distributed-lock.tssrc/lib/openapi.tssrc/lib/packages-server.tssrc/lib/permissions.tssrc/lib/saml.tssrc/lib/scim.tssrc/pages/api/admin/backup.tssrc/pages/api/auth/saml/metadata.tssrc/pages/api/packages/npm/[...path].tssrc/pages/api/scim/v2/Groups.tssrc/pages/api/scim/v2/Groups/[id].tssrc/pages/api/scim/v2/ServiceProviderConfig.tssrc/pages/api/scim/v2/Users.tssrc/pages/api/scim/v2/Users/[id].tssrc/pages/api/v2/[...path].tssrc/pages/api/v2/index.tstests/integration/file-approvals-path-permissions.test.tstests/unit/scim-saml.test.ts
▼.github/workflows/docker-publish.yml
+62-17
| @@ -22,6 +22,9 @@ jobs: | ||
22 | 22 | - name: Checkout |
23 | 23 | uses: actions/checkout@v4 |
24 | 24 | |
25 | + - name: Set up QEMU | |
26 | + uses: docker/setup-qemu-action@v3 | |
27 | + | |
25 | 28 | - name: Set up Docker Buildx |
26 | 29 | uses: docker/setup-buildx-action@v3 |
27 | 30 | |
| @@ -31,8 +34,8 @@ jobs: | ||
31 | 34 | username: ${{ secrets.DOCKER_USERNAME }} |
32 | 35 | password: ${{ secrets.DOCKER_TOKEN }} |
33 | 36 | |
34 | - - name: Extract metadata | |
35 | - id: meta | |
37 | + - name: Extract Metadata (App) | |
38 | + id: meta-app | |
36 | 39 | uses: docker/metadata-action@v5 |
37 | 40 | with: |
38 | 41 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} |
| @@ -43,41 +46,83 @@ jobs: | ||
43 | 46 | type=sha,prefix= |
44 | 47 | type=raw,value=latest,enable={{is_default_branch}} |
45 | 48 | labels: | |
46 | - org.opencontainers.image.title=OpenCodeHub | |
47 | - org.opencontainers.image.description=A modern, self-hosted Git platform for teams that want speed, control, and clean workflows. | |
49 | + org.opencontainers.image.title=OpenCodeHub Platform | |
50 | + org.opencontainers.image.description=A modern, self-hosted Git platform with stacked PRs, merge queue, CI/CD, and AI review. | |
51 | + org.opencontainers.image.url=https://github.com/swadhinbiswas/OpencodeHub | |
52 | + org.opencontainers.image.source=https://github.com/swadhinbiswas/OpencodeHub | |
53 | + org.opencontainers.image.licenses=MIT | |
54 | + | |
55 | + - name: Extract Metadata (Worker) | |
56 | + id: meta-worker | |
57 | + uses: docker/metadata-action@v5 | |
58 | + with: | |
59 | + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-worker | |
60 | + tags: | | |
61 | + type=ref,event=branch | |
62 | + type=semver,pattern={{version}} | |
63 | + type=semver,pattern={{major}}.{{minor}} | |
64 | + type=sha,prefix= | |
65 | + type=raw,value=latest,enable={{is_default_branch}} | |
66 | + labels: | | |
67 | + org.opencontainers.image.title=OpenCodeHub Worker | |
68 | + org.opencontainers.image.description=Background task worker for OpenCodeHub (queues, webhooks, notifications). | |
48 | 69 | org.opencontainers.image.url=https://github.com/swadhinbiswas/OpencodeHub |
49 | 70 | org.opencontainers.image.source=https://github.com/swadhinbiswas/OpencodeHub |
50 | 71 | org.opencontainers.image.licenses=MIT |
51 | 72 | |
52 | - - name: Build and push app image | |
73 | + - name: Extract Metadata (Runner) | |
74 | + id: meta-runner | |
75 | + uses: docker/metadata-action@v5 | |
76 | + with: | |
77 | + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-runner | |
78 | + tags: | | |
79 | + type=ref,event=branch | |
80 | + type=semver,pattern={{version}} | |
81 | + type=semver,pattern={{major}}.{{minor}} | |
82 | + type=sha,prefix= | |
83 | + type=raw,value=latest,enable={{is_default_branch}} | |
84 | + labels: | | |
85 | + org.opencontainers.image.title=OpenCodeHub Runner | |
86 | + org.opencontainers.image.description=Docker-in-Docker CI/CD runner for OpenCodeHub pipelines. | |
87 | + org.opencontainers.image.url=https://github.com/swadhinbiswas/OpencodeHub | |
88 | + org.opencontainers.image.source=https://github.com/swadhinbiswas/OpencodeHub | |
89 | + org.opencontainers.image.licenses=MIT | |
90 | + | |
91 | + - name: Build and Push App Image | |
53 | 92 | uses: docker/build-push-action@v5 |
54 | 93 | with: |
55 | 94 | context: . |
56 | 95 | file: ./Dockerfile |
57 | 96 | push: true |
58 | - tags: ${{ steps.meta.outputs.tags }} | |
59 | - labels: ${{ steps.meta.outputs.labels }} | |
97 | + tags: ${{ steps.meta-app.outputs.tags }} | |
98 | + labels: ${{ steps.meta-app.outputs.labels }} | |
60 | 99 | cache-from: type=gha |
61 | 100 | cache-to: type=gha,mode=max |
62 | 101 | platforms: linux/amd64,linux/arm64 |
63 | 102 | |
64 | - - name: Build and push runner image | |
103 | + - name: Build and Push Worker Image | |
104 | + uses: docker/build-push-action@v5 | |
105 | + with: | |
106 | + context: . | |
107 | + file: ./Dockerfile.worker | |
108 | + push: true | |
109 | + tags: ${{ steps.meta-worker.outputs.tags }} | |
110 | + labels: ${{ steps.meta-worker.outputs.labels }} | |
111 | + cache-from: type=gha | |
112 | + cache-to: type=gha,mode=max | |
113 | + platforms: linux/amd64,linux/arm64 | |
114 | + | |
115 | + - name: Build and Push Runner Image | |
65 | 116 | uses: docker/build-push-action@v5 |
66 | 117 | with: |
67 | 118 | context: . |
68 | 119 | file: ./Dockerfile.runner |
69 | 120 | push: true |
70 | - tags: | | |
71 | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-runner:latest | |
72 | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-runner:${{ github.sha }} | |
73 | - labels: | | |
74 | - org.opencontainers.image.title=OpenCodeHub Runner | |
75 | - org.opencontainers.image.description=CI/CD runner for OpenCodeHub | |
76 | - org.opencontainers.image.url=https://github.com/swadhinbiswas/OpencodeHub | |
77 | - org.opencontainers.image.source=https://github.com/swadhinbiswas/OpencodeHub | |
78 | - org.opencontainers.image.licenses=MIT | |
121 | + tags: ${{ steps.meta-runner.outputs.tags }} | |
122 | + labels: ${{ steps.meta-runner.outputs.labels }} | |
79 | 123 | cache-from: type=gha |
80 | 124 | cache-to: type=gha,mode=max |
125 | + platforms: linux/amd64,linux/arm64 | |
81 | 126 | |
82 | 127 | - name: Update Docker Hub Repository Description |
83 | 128 | uses: peter-evans/dockerhub-description@v4 |
84 | 129 | |
▼DOCKERHUB.md
+165-39
| @@ -1,81 +1,207 @@ | ||
1 | 1 | # OpenCodeHub |
2 | 2 | |
3 | - | |
3 | +[](https://hub.docker.com/r/opencodehub/opencodehub) | |
4 | +[](https://hub.docker.com/r/opencodehub/opencodehub) | |
5 | +[](https://opensource.org/licenses/MIT) | |
6 | +[](https://docs.opencodehub.space) | |
4 | 7 | |
5 | -**The self-hosted Git platform that doesn't compromise.** | |
8 | +**The self-hosted Git platform built for speed, control, and modern software engineering workflows.** | |
6 | 9 | |
7 | -OpenCodeHub is a self-hosted Git platform with stacked PRs, merge queue, CI/CD pipelines, and AI code review. One platform for everything your team needs — no vendor lock-in, no per-seat pricing. | |
10 | +OpenCodeHub is an open-source alternative to GitHub/GitLab featuring native Graphite-style **Stacked PRs**, a **Merge Queue with speculative builds**, a **GitHub Actions-compatible CI/CD engine**, and multi-provider **AI Code Review**. | |
8 | 11 | |
9 | 12 | --- |
10 | 13 | |
11 | -## Features | |
14 | +## Key Features | |
12 | 15 | |
13 | -- **Stacked PRs** — Graphite-style stacked branches for incremental review | |
14 | -- **Merge Queue** — Stack-aware merge ordering with speculative CI builds | |
15 | -- **CI/CD Pipelines** — GitHub Actions-compatible workflows with Docker runners | |
16 | -- **AI Code Review** — 10+ providers: GPT-4, Claude, Gemini, Groq, Ollama | |
17 | -- **Git Hosting** — HTTP smart protocol + SSH push/pull, forks, mirroring, LFS | |
18 | -- **Issues & Projects** — Labels, milestones, custom fields, kanban boards | |
19 | -- **175+ API Endpoints** — REST + GraphQL for full programmatic access | |
20 | -- **CLI** — `och` command line tool for stack workflows and repository management | |
16 | +- 🥞 **Stacked PRs** — Native stacked branch workflows in Web UI & CLI (`och stack create/submit/sync`) | |
17 | +- 🚦 **Merge Queue** — Priority lanes, speculatively tested builds, and auto-merge controls | |
18 | +- ⚡ **CI/CD Pipeline Engine** — GitHub Actions workflow runner using Docker-in-Docker execution | |
19 | +- 🤖 **AI Code Review** — Multi-provider support (OpenAI, Anthropic, Gemini, Groq, local Ollama) | |
20 | +- 🔒 **Enterprise Security** — Secret scanning, Trivy vulnerability scans, license compliance, OIDC SSO, 2FA/TOTP, and path permissions | |
21 | +- 📦 **Pluggable Storage** — Local filesystem, AWS S3, Cloudflare R2, MinIO, Ceph, Garage, and any S3-compatible backend | |
22 | +- 🗄️ **Multi-Database Support** — PostgreSQL (recommended for production), SQLite, and Turso/LibSQL | |
23 | +- 🧰 **Developer CLI** — `opencodehub-cli` (`och`) with interactive terminal cockpit (`och focus`) and stack visualization | |
24 | + | |
25 | +--- | |
26 | + | |
27 | +## Architecture Overview | |
28 | + | |
29 | +``` | |
30 | +┌─────────────────────────────────────────────────────────────┐ | |
31 | +│ CLIENTS │ | |
32 | +│ Browser │ Git CLI (HTTP/SSH) │ OpenCodeHub CLI (och) │ | |
33 | +└─────────────────────────────────────────────────────────────┘ | |
34 | + │ | |
35 | +┌─────────────────────────────────────────────────────────────┐ | |
36 | +│ OPENCODEHUB PLATFORM │ | |
37 | +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ | |
38 | +│ │ Web UI │ │ REST API │ │ GraphQL Endpoint │ │ | |
39 | +│ │ (Astro+React)││ (140+ routes)││ (src/pages/api/) │ │ | |
40 | +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ | |
41 | +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ | |
42 | +│ │ Git Server │ │ SSH Server │ │ Pipeline Runner │ │ | |
43 | +│ │ (HTTP RPC) │ │ (ssh2) │ │ (Docker executor) │ │ | |
44 | +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ | |
45 | +└─────────────────────────────────────────────────────────────┘ | |
46 | + │ | |
47 | +┌─────────────────────────────────────────────────────────────┐ | |
48 | +│ PERSISTENCE & INFRASTRUCTURE │ | |
49 | +│ PostgreSQL / SQLite │ Redis │ S3 / MinIO / Local │ | |
50 | +└─────────────────────────────────────────────────────────────┘ | |
51 | +``` | |
21 | 52 | |
22 | 53 | --- |
23 | 54 | |
24 | 55 | ## Quick Start |
25 | 56 | |
57 | +### 1. Single Container (Quick Test) | |
58 | + | |
26 | 59 | ```bash |
27 | -# Pull and run | |
28 | 60 | docker run -d \ |
29 | 61 | --name opencodehub \ |
30 | 62 | -p 4321:4321 \ |
63 | + -p 2222:2222 \ | |
31 | 64 | -v opencodehub-data:/data \ |
32 | 65 | -e JWT_SECRET=$(openssl rand -hex 32) \ |
33 | 66 | -e SESSION_SECRET=$(openssl rand -hex 32) \ |
34 | - -e DATABASE_URL=postgresql://user:pass@db:5432/opencodehub \ | |
67 | + -e INTERNAL_HOOK_SECRET=$(openssl rand -hex 32) \ | |
68 | + -e SITE_URL=http://localhost:4321 \ | |
35 | 69 | opencodehub/opencodehub:latest |
36 | 70 | ``` |
37 | 71 | |
38 | -Or with Docker Compose: | |
72 | +Open **http://localhost:4321** in your browser to get started. | |
73 | + | |
74 | +--- | |
39 | 75 | |
76 | +### 2. Production Docker Compose Stack (Recommended) | |
77 | + | |
78 | +```yaml | |
79 | +version: "3.8" | |
80 | + | |
81 | +services: | |
82 | + app: | |
83 | + image: opencodehub/opencodehub:latest | |
84 | + container_name: opencodehub-app | |
85 | + restart: always | |
86 | + ports: | |
87 | + - "4321:4321" # Web UI & API | |
88 | + - "2222:2222" # Git SSH Server | |
89 | + environment: | |
90 | + - NODE_ENV=production | |
91 | + - SITE_URL=https://git.yourdomain.com | |
92 | + - DATABASE_DRIVER=postgres | |
93 | + - DATABASE_URL=postgresql://opencodehub:secretpass@postgres:5432/opencodehub?sslmode=disable | |
94 | + - REDIS_URL=redis://redis:6379 | |
95 | + - JWT_SECRET=${JWT_SECRET} | |
96 | + - SESSION_SECRET=${SESSION_SECRET} | |
97 | + - INTERNAL_HOOK_SECRET=${INTERNAL_HOOK_SECRET} | |
98 | + - RUNNER_SECRET=${RUNNER_SECRET} | |
99 | + - WORKFLOW_SECRET_ENCRYPTION_KEY=${WORKFLOW_SECRET_ENCRYPTION_KEY} | |
100 | + volumes: | |
101 | + - app-data:/data | |
102 | + depends_on: | |
103 | + - postgres | |
104 | + - redis | |
105 | + | |
106 | + worker: | |
107 | + image: opencodehub/opencodehub-worker:latest | |
108 | + container_name: opencodehub-worker | |
109 | + restart: always | |
110 | + environment: | |
111 | + - NODE_ENV=production | |
112 | + - DATABASE_DRIVER=postgres | |
113 | + - DATABASE_URL=postgresql://opencodehub:secretpass@postgres:5432/opencodehub?sslmode=disable | |
114 | + - REDIS_URL=redis://redis:6379 | |
115 | + volumes: | |
116 | + - app-data:/data | |
117 | + depends_on: | |
118 | + - postgres | |
119 | + - redis | |
120 | + | |
121 | + runner: | |
122 | + image: opencodehub/opencodehub-runner:latest | |
123 | + container_name: opencodehub-runner | |
124 | + restart: always | |
125 | + privileged: true | |
126 | + environment: | |
127 | + - OPENCODEHUB_URL=http://app:4321 | |
128 | + - RUNNER_SECRET=${RUNNER_SECRET} | |
129 | + volumes: | |
130 | + - /var/run/docker.sock:/var/run/docker.sock | |
131 | + depends_on: | |
132 | + - app | |
133 | + | |
134 | + postgres: | |
135 | + image: postgres:16-alpine | |
136 | + container_name: opencodehub-postgres | |
137 | + restart: always | |
138 | + environment: | |
139 | + POSTGRES_USER: opencodehub | |
140 | + POSTGRES_PASSWORD: secretpass | |
141 | + POSTGRES_DB: opencodehub | |
142 | + volumes: | |
143 | + - pg-data:/var/lib/postgresql/data | |
144 | + | |
145 | + redis: | |
146 | + image: redis:7-alpine | |
147 | + container_name: opencodehub-redis | |
148 | + restart: always | |
149 | + volumes: | |
150 | + - redis-data:/data | |
151 | + | |
152 | +volumes: | |
153 | + app-data: | |
154 | + pg-data: | |
155 | + redis-data: | |
156 | +``` | |
157 | + | |
158 | +Start the stack: | |
40 | 159 | ```bash |
41 | -git clone https://github.com/swadhinbiswas/OpencodeHub.git | |
42 | -cd OpenCodeHub | |
43 | -cp .env.example .env | |
44 | 160 | docker compose up -d |
45 | 161 | docker compose exec app bun run scripts/seed-admin.ts |
46 | 162 | ``` |
47 | 163 | |
48 | -Open **http://localhost:4321** and create your admin account. | |
49 | - | |
50 | 164 | --- |
51 | 165 | |
52 | -## Environment Variables | |
53 | - | |
54 | -| Variable | Required | Description | | |
55 | -|----------|----------|-------------| | |
56 | -| `DATABASE_URL` | Yes | PostgreSQL connection string | | |
57 | -| `JWT_SECRET` | Yes | Random 32+ char string for JWT signing | | |
58 | -| `SESSION_SECRET` | Yes | Random 32+ char string for session encryption | | |
59 | -| `REDIS_URL` | Yes | Redis connection string | | |
60 | -| `SITE_URL` | Yes | Your public URL (e.g., `https://git.example.com`) | | |
61 | -| `STORAGE_TYPE` | No | `local` (default) or `s3` | | |
166 | +## Environment Variables Reference | |
167 | + | |
168 | +| Variable | Required | Default | Description | | |
169 | +|----------|----------|---------|-------------| | |
170 | +| `SITE_URL` | Yes | `http://localhost:4321` | Base URL of your platform (e.g. `https://git.company.com`) | | |
171 | +| `DATABASE_DRIVER` | No | `postgres` | Database driver (`postgres`, `sqlite`, `turso`) | | |
172 | +| `DATABASE_URL` | Yes | — | Connection string (PostgreSQL/SQLite) | | |
173 | +| `REDIS_URL` | Yes | — | Redis connection URI for sessions & locks | | |
174 | +| `JWT_SECRET` | Yes | — | 32+ char secret for JWT token signing | | |
175 | +| `SESSION_SECRET` | Yes | — | 32+ char secret for cookie encryption | | |
176 | +| `INTERNAL_HOOK_SECRET` | Yes | — | Shared secret for Git hook callbacks | | |
177 | +| `STORAGE_TYPE` | No | `local` | Storage backend (`local` or `s3`) | | |
178 | +| `S3_BUCKET` | If `STORAGE_TYPE=s3` | — | S3 bucket name | | |
179 | +| `S3_ENDPOINT` | Optional | — | S3-compatible custom endpoint (MinIO / R2) | | |
180 | +| `S3_ACCESS_KEY` | If `STORAGE_TYPE=s3` | — | Access Key ID | | |
181 | +| `S3_SECRET_KEY` | If `STORAGE_TYPE=s3` | — | Secret Access Key | | |
62 | 182 | |
63 | 183 | --- |
64 | 184 | |
65 | -## Documentation | |
185 | +## OpenCodeHub CLI (`och`) | |
66 | 186 | |
67 | -Full documentation: **[docs.opencodehub.space](https://docs.opencodehub.space)** | |
187 | +Install the official CLI to manage stacks, merge queues, and reviews: | |
68 | 188 | |
69 | -- [Installation Guide](https://docs.opencodehub.space/getting-started/installation/) | |
70 | -- [Configuration Reference](https://docs.opencodehub.space/administration/configuration/) | |
71 | -- [Deployment Options](https://docs.opencodehub.space/administration/deployment/) | |
72 | -- [CLI Reference](https://docs.opencodehub.space/reference/cli-commands/) | |
73 | -- [API Reference](https://docs.opencodehub.space/api/rest-api/) | |
189 | +```bash | |
190 | +npm install -g opencodehub-cli | |
191 | + | |
192 | +# Authenticate with your self-hosted instance | |
193 | +och auth login --url https://git.yourdomain.com | |
194 | + | |
195 | +# Create stacked PR branches | |
196 | +och stack create feature/part-1 | |
197 | +och stack submit | |
198 | +``` | |
74 | 199 | |
75 | 200 | --- |
76 | 201 | |
77 | -## Links | |
202 | +## Useful Links & Community | |
78 | 203 | |
79 | -- **GitHub**: [github.com/swadhinbiswas/OpencodeHub](https://github.com/swadhinbiswas/OpencodeHub) | |
204 | +- **Official Repository**: [github.com/swadhinbiswas/OpencodeHub](https://github.com/swadhinbiswas/OpencodeHub) | |
80 | 205 | - **Documentation**: [docs.opencodehub.space](https://docs.opencodehub.space) |
206 | +- **Bug Tracker**: [github.com/swadhinbiswas/OpencodeHub/issues](https://github.com/swadhinbiswas/OpencodeHub/issues) | |
81 | 207 | - **License**: MIT |
82 | 208 | |
▼doc/docs-parity-baseline.json
+2-0
| @@ -1,5 +1,7 @@ | ||
1 | 1 | { |
2 | 2 | "missingInSite": [ |
3 | + "cli", | |
4 | + "getting-started/ssh-keys", | |
3 | 5 | "support/troubleshooting" |
4 | 6 | ], |
5 | 7 | "missingInDocs": [ |
6 | 8 | |
▼docker-entrypoint.sh
+7-1
| @@ -26,7 +26,13 @@ if [ -n "$DATABASE_URL" ]; then | ||
26 | 26 | # Run database migrations |
27 | 27 | echo "🔄 Running database migrations..." |
28 | 28 | cd /app |
29 | - bunx drizzle-kit push --force 2>/dev/null || echo "⚠️ Migration push skipped (may already be up to date)" | |
29 | + # Generate migration files if they don't exist yet (safe — only creates new ones) | |
30 | + if [ ! -d "/app/drizzle" ]; then | |
31 | + echo "📝 Generating initial migration files..." | |
32 | + bunx drizzle-kit generate 2>/dev/null || echo "⚠️ Migration generation skipped" | |
33 | + fi | |
34 | + # Apply pending migrations (safe — never drops columns/tables like push --force) | |
35 | + bunx drizzle-kit migrate 2>/dev/null || echo "⚠️ Migration apply skipped (may already be up to date)" | |
30 | 36 | echo "✅ Migrations complete" |
31 | 37 | fi |
32 | 38 | |
33 | 39 | |
▼scripts/worker.ts
+143-108
| @@ -10,37 +10,88 @@ import { createServer } from "http"; | ||
10 | 10 | |
11 | 11 | // ── Configuration ──────────────────────────────────────── ───────────────────── |
12 | 12 | const WORKER_INTERVAL = parseInt(process.env.WORKER_INTERVAL || "5000", 10); |
13 | -const MIRROR_SYNC_INTERVAL = parseInt( | |
14 | - process.env.MIRROR_SYNC_INTERVAL || "60000", | |
15 | - 10, | |
16 | -); | |
17 | -const CLEANUP_INTERVAL = parseInt( | |
18 | - process.env.CLEANUP_INTERVAL || "3600000", | |
19 | - 10, | |
20 | -); | |
21 | -const DIGEST_INTERVAL = parseInt( | |
22 | - process.env.DIGEST_INTERVAL || "300000", // Every 5 minutes | |
23 | - 10, | |
24 | -); | |
13 | +const MIRROR_SYNC_INTERVAL = parseInt(process.env.MIRROR_SYNC_INTERVAL || "60000", 10); | |
14 | +const CLEANUP_INTERVAL = parseInt(process.env.CLEANUP_INTERVAL || "3600000", 10); | |
15 | +const DIGEST_INTERVAL = parseInt(process.env.DIGEST_INTERVAL || "300000", 10); | |
25 | 16 | const HEALTH_PORT = parseInt(process.env.WORKER_HEALTH_PORT || "9090", 10); |
26 | 17 | const MAX_RETRIES = parseInt(process.env.WORKER_MAX_RETRIES || "3", 10); |
27 | -const STALE_JOB_TIMEOUT_MS = parseInt( | |
28 | - process.env.WORKER_STALE_TIMEOUT || "300000", | |
29 | - 10, | |
30 | -); | |
18 | +const STALE_JOB_TIMEOUT_MS = parseInt(process.env.WORKER_STALE_TIMEOUT || "300000", 10); | |
19 | + | |
20 | +// Circuit breaker settings | |
21 | +const CIRCUIT_BREAKER_THRESHOLD = parseInt(process.env.WORKER_CB_THRESHOLD || "10", 10); | |
22 | +const CIRCUIT_BREAKER_RESET_MS = parseInt(process.env.WORKER_CB_RESET_MS || "60000", 10); | |
31 | 23 | |
32 | 24 | // ── State ───────────────────────────────────────────────────────────────────── |
33 | 25 | let isShuttingDown = false; |
34 | -let isHealthy = true; | |
35 | -let lastQueueRun = Date.now(); | |
26 | +let lastQueueRun = 0; | |
36 | 27 | let lastMirrorRun = 0; |
37 | 28 | let lastCleanupRun = 0; |
38 | 29 | let lastDigestRun = 0; |
39 | -let consecutiveErrors = 0; | |
40 | -const MAX_CONSECUTIVE_ERRORS = 10; | |
30 | + | |
31 | +// Circuit breaker state per task | |
32 | +interface CircuitBreakerState { | |
33 | + failures: number; | |
34 | + lastFailureTime: number; | |
35 | + isOpen: boolean; | |
36 | + openedAt: number; | |
37 | +} | |
38 | +const circuitBreakers = new Map<string, CircuitBreakerState>(); | |
39 | + | |
40 | +function getCircuitBreaker(name: string): CircuitBreakerState { | |
41 | + let cb = circuitBreakers.get(name); | |
42 | + if (!cb) { | |
43 | + cb = { failures: 0, lastFailureTime: 0, isOpen: false, openedAt: 0 }; | |
44 | + circuitBreakers.set(name, cb); | |
45 | + } | |
46 | + return cb; | |
47 | +} | |
48 | + | |
49 | +function isCircuitOpen(name: string): boolean { | |
50 | + const cb = getCircuitBreaker(name); | |
51 | + if (!cb.isOpen) return false; | |
52 | + if (Date.now() - cb.openedAt > CIRCUIT_BREAKER_RESET_MS) { | |
53 | + cb.isOpen = false; | |
54 | + cb.failures = 0; | |
55 | + logger.info({ task: name }, "Circuit breaker reset (half-open)"); | |
56 | + return false; | |
57 | + } | |
58 | + return true; | |
59 | +} | |
60 | + | |
61 | +function recordSuccess(name: string): void { | |
62 | + const cb = getCircuitBreaker(name); | |
63 | + cb.failures = 0; | |
64 | + cb.isOpen = false; | |
65 | +} | |
66 | + | |
67 | +function recordFailure(name: string): void { | |
68 | + const cb = getCircuitBreaker(name); | |
69 | + cb.failures++; | |
70 | + cb.lastFailureTime = Date.now(); | |
71 | + if (cb.failures >= CIRCUIT_BREAKER_THRESHOLD) { | |
72 | + cb.isOpen = true; | |
73 | + cb.openedAt = Date.now(); | |
74 | + logger.error({ task: name, failures: cb.failures }, "Circuit breaker OPENED"); | |
75 | + } | |
76 | +} | |
77 | + | |
78 | +// ── Exponential Backoff ──────────────────────────────────────────────────────── | |
79 | +async function backoff(attempt: number): Promise<void> { | |
80 | + const delay = Math.min(1000 * Math.pow(2, attempt), 60000); | |
81 | + await sleep(delay + Math.random() * 1000); | |
82 | +} | |
83 | + | |
84 | +function sleep(ms: number): Promise<void> { | |
85 | + return new Promise((resolve) => setTimeout(resolve, ms)); | |
86 | +} | |
41 | 87 | |
42 | 88 | // ── Queue Processor ─────────────────────────────────────────────────────────── |
43 | 89 | async function runQueueProcessor() { |
90 | + if (isCircuitOpen("queue-processor")) { | |
91 | + logger.warn("Queue processor circuit open — skipping"); | |
92 | + return; | |
93 | + } | |
94 | + | |
44 | 95 | const db = getDatabase(); |
45 | 96 | |
46 | 97 | try { |
| @@ -49,19 +100,28 @@ async function runQueueProcessor() { | ||
49 | 100 | const staleThreshold = new Date(Date.now() - STALE_JOB_TIMEOUT_MS); |
50 | 101 | const staleItems = await db.query.mergeQueueItems.findMany({ |
51 | 102 | where: eq(schema.mergeQueueItems.status, "running"), |
52 | - columns: { id: true, startedAt: true }, | |
103 | + columns: { id: true, startedAt: true, attemptCount: true }, | |
53 | 104 | }); |
54 | 105 | for (const item of staleItems) { |
55 | 106 | if (item.startedAt && new Date(item.startedAt) < staleThreshold) { |
56 | - await (db as NodePgDatabase<typeof schema>) | |
57 | - .update(schema.mergeQueueItems) | |
58 | - .set({ status: "queued" }) | |
59 | - .where(eq(schema.mergeQueueItems.id, item.id)); | |
60 | - logger.warn({ itemId: item.id }, "Reclaimed stale queue item"); | |
107 | + const attempts = (item.attemptCount || 0) + 1; | |
108 | + if (attempts > MAX_RETRIES) { | |
109 | + await (db as NodePgDatabase<typeof schema>) | |
110 | + .update(schema.mergeQueueItems) | |
111 | + .set({ status: "failed", completedAt: new Date() }) | |
112 | + .where(eq(schema.mergeQueueItems.id, item.id)); | |
113 | + logger.warn({ itemId: item.id, attempts }, "Queue item exceeded max retries — moved to dead letter"); | |
114 | + } else { | |
115 | + await (db as NodePgDatabase<typeof schema>) | |
116 | + .update(schema.mergeQueueItems) | |
117 | + .set({ status: "queued" }) | |
118 | + .where(eq(schema.mergeQueueItems.id, item.id)); | |
119 | + logger.warn({ itemId: item.id, attempts }, "Reclaimed stale queue item with backoff"); | |
120 | + } | |
61 | 121 | } |
62 | 122 | } |
63 | - } catch { | |
64 | - // startedAt column may not exist yet — skip stale reclaim | |
123 | + } catch (err) { | |
124 | + logger.error({ err }, "Stale job reclaim failed (non-fatal)"); | |
65 | 125 | } |
66 | 126 | |
67 | 127 | // 2. Process pending items |
| @@ -74,36 +134,26 @@ async function runQueueProcessor() { | ||
74 | 134 | |
75 | 135 | if (repoIds.length > 0) { |
76 | 136 | logger.info({ repoCount: repoIds.length }, "Processing merge queues"); |
77 | - // Process sequentially to avoid race conditions | |
78 | 137 | for (const repoId of repoIds) { |
79 | 138 | if (isShuttingDown) break; |
80 | 139 | try { |
81 | 140 | await queueWorker.processQueue(repoId); |
82 | 141 | } catch (error) { |
83 | - logger.error( | |
84 | - { err: error, repoId }, | |
85 | - "Failed to process queue for repo", | |
86 | - ); | |
142 | + logger.error({ err: error, repoId }, "Failed to process queue for repo"); | |
143 | + recordFailure("queue-processor"); | |
87 | 144 | } |
88 | 145 | } |
89 | 146 | } |
90 | 147 | |
91 | - consecutiveErrors = 0; | |
148 | + recordSuccess("queue-processor"); | |
92 | 149 | lastQueueRun = Date.now(); |
93 | 150 | } catch (error) { |
94 | - consecutiveErrors++; | |
95 | - logger.error( | |
96 | - { err: error, consecutiveErrors }, | |
97 | - "Error in queue processor loop", | |
98 | - ); | |
99 | - if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { | |
100 | - isHealthy = false; | |
101 | - logger.error("Too many consecutive errors. Worker marked unhealthy."); | |
102 | - } | |
151 | + recordFailure("queue-processor"); | |
152 | + logger.error({ err: error }, "Fatal error in queue processor loop"); | |
103 | 153 | } |
104 | 154 | } |
105 | 155 | |
106 | -// ── Controlled Loop (replaces setInterval) ──────────────────────────────────── | |
156 | +// ── Controlled Loop with Exponential Backoff ─────────────────────────────────── | |
107 | 157 | async function runLoop( |
108 | 158 | name: string, |
109 | 159 | fn: () => Promise<void>, |
| @@ -111,13 +161,25 @@ async function runLoop( | ||
111 | 161 | getLastRun: () => number, |
112 | 162 | setLastRun: (t: number) => void, |
113 | 163 | ) { |
164 | + let attempt = 0; | |
114 | 165 | while (!isShuttingDown) { |
115 | 166 | const elapsed = Date.now() - getLastRun(); |
116 | 167 | if (elapsed >= intervalMs) { |
168 | + if (isCircuitOpen(name)) { | |
169 | + logger.warn({ task: name }, `Circuit open — skipping ${name}`); | |
170 | + await sleep(Math.min(intervalMs, 5000)); | |
171 | + continue; | |
172 | + } | |
117 | 173 | try { |
118 | 174 | await fn(); |
175 | + attempt = 0; | |
119 | 176 | } catch (error) { |
120 | - logger.error({ err: error, task: name }, `${name} failed`); | |
177 | + attempt++; | |
178 | + logger.error({ err: error, task: name, attempt }, `${name} failed`); | |
179 | + recordFailure(name); | |
180 | + if (attempt < MAX_RETRIES) { | |
181 | + await backoff(attempt); | |
182 | + } | |
121 | 183 | } |
122 | 184 | setLastRun(Date.now()); |
123 | 185 | } |
| @@ -125,31 +187,39 @@ async function runLoop( | ||
125 | 187 | } |
126 | 188 | } |
127 | 189 | |
128 | -function sleep(ms: number): Promise<void> { | |
129 | - return new Promise((resolve) => setTimeout(resolve, ms)); | |
130 | -} | |
131 | - | |
132 | 190 | // ── Health Check Server ─────────────────────────────────────────────────────── |
133 | 191 | function startHealthServer() { |
134 | 192 | const server = createServer((req, res) => { |
135 | 193 | if (req.url === "/healthz" || req.url === "/health") { |
136 | 194 | const timeSinceLastRun = Date.now() - lastQueueRun; |
137 | - const healthy = isHealthy && timeSinceLastRun < WORKER_INTERVAL * 5; | |
138 | - res.writeHead(healthy ? 200 : 503, { | |
139 | - "Content-Type": "application/json", | |
140 | - }); | |
141 | - res.end( | |
142 | - JSON.stringify({ | |
143 | - status: healthy ? "healthy" : "unhealthy", | |
144 | - uptime: process.uptime(), | |
145 | - lastQueueRun: new Date(lastQueueRun).toISOString(), | |
146 | - consecutiveErrors, | |
147 | - shuttingDown: isShuttingDown, | |
148 | - }), | |
149 | - ); | |
195 | + const healthy = !isShuttingDown && timeSinceLastRun < WORKER_INTERVAL * 10; | |
196 | + res.writeHead(healthy ? 200 : 503, { "Content-Type": "application/json" }); | |
197 | + res.end(JSON.stringify({ | |
198 | + status: healthy ? "healthy" : "unhealthy", | |
199 | + uptime: process.uptime(), | |
200 | + lastQueueRun: new Date(lastQueueRun).toISOString(), | |
201 | + circuitBreakers: Object.fromEntries( | |
202 | + Array.from(circuitBreakers.entries()).map(([k, v]) => [ | |
203 | + k, | |
204 | + { isOpen: v.isOpen, failures: v.failures }, | |
205 | + ]), | |
206 | + ), | |
207 | + shuttingDown: isShuttingDown, | |
208 | + })); | |
150 | 209 | } else if (req.url === "/ready") { |
151 | - res.writeHead(isHealthy ? 200 : 503); | |
152 | - res.end(isHealthy ? "ready" : "not ready"); | |
210 | + const ready = !isShuttingDown; | |
211 | + res.writeHead(ready ? 200 : 503); | |
212 | + res.end(ready ? "ready" : "not ready"); | |
213 | + } else if (req.url === "/metrics") { | |
214 | + res.writeHead(200, { "Content-Type": "text/plain" }); | |
215 | + const lines: string[] = []; | |
216 | + lines.push("# HELP worker_circuit_breaker_state Circuit breaker open/closed per task"); | |
217 | + lines.push("# TYPE worker_circuit_breaker_state gauge"); | |
218 | + for (const [name, cb] of circuitBreakers) { | |
219 | + lines.push(`worker_circuit_breaker_state{task="${name}"} ${cb.isOpen ? 1 : 0}`); | |
220 | + lines.push(`worker_circuit_breaker_failures{task="${name}"} ${cb.failures}`); | |
221 | + } | |
222 | + res.end(lines.join("\n") + "\n"); | |
153 | 223 | } else { |
154 | 224 | res.writeHead(404); |
155 | 225 | res.end("Not Found"); |
| @@ -169,8 +239,6 @@ function setupGracefulShutdown(healthServer: ReturnType<typeof createServer>) { | ||
169 | 239 | isShuttingDown = true; |
170 | 240 | logger.info({ signal }, "Worker shutting down gracefully..."); |
171 | 241 | healthServer.close(); |
172 | - | |
173 | - // Give in-flight jobs up to 10s to complete | |
174 | 242 | await sleep(10_000); |
175 | 243 | logger.info("Worker shutdown complete."); |
176 | 244 | process.exit(0); |
| @@ -180,7 +248,6 @@ function setupGracefulShutdown(healthServer: ReturnType<typeof createServer>) { | ||
180 | 248 | process.on("SIGTERM", () => shutdown("SIGTERM")); |
181 | 249 | process.on("uncaughtException", (err) => { |
182 | 250 | logger.error({ err }, "Uncaught exception in worker"); |
183 | - isHealthy = false; | |
184 | 251 | }); |
185 | 252 | process.on("unhandledRejection", (reason) => { |
186 | 253 | logger.error({ reason }, "Unhandled rejection in worker"); |
| @@ -196,6 +263,8 @@ async function startWorker() { | ||
196 | 263 | cleanupInterval: CLEANUP_INTERVAL, |
197 | 264 | healthPort: HEALTH_PORT, |
198 | 265 | maxRetries: MAX_RETRIES, |
266 | + circuitBreakerThreshold: CIRCUIT_BREAKER_THRESHOLD, | |
267 | + circuitBreakerResetMs: CIRCUIT_BREAKER_RESET_MS, | |
199 | 268 | }, |
200 | 269 | "Starting OpenCodeHub Background Worker...", |
201 | 270 | ); |
| @@ -203,50 +272,16 @@ async function startWorker() { | ||
203 | 272 | const healthServer = startHealthServer(); |
204 | 273 | setupGracefulShutdown(healthServer); |
205 | 274 | |
206 | - // Run initial processing immediately | |
207 | 275 | await runQueueProcessor(); |
208 | 276 | |
209 | - // Start concurrent task loops | |
210 | 277 | await Promise.all([ |
211 | - runLoop( | |
212 | - "queue-processor", | |
213 | - runQueueProcessor, | |
214 | - WORKER_INTERVAL, | |
215 | - () => lastQueueRun, | |
216 | - (t) => { | |
217 | - lastQueueRun = t; | |
218 | - }, | |
219 | - ), | |
220 | - runLoop( | |
221 | - "mirror-sync", | |
222 | - async () => { await syncAllMirrors(); }, | |
223 | - MIRROR_SYNC_INTERVAL, | |
224 | - () => lastMirrorRun, | |
225 | - (t) => { | |
226 | - lastMirrorRun = t; | |
227 | - }, | |
228 | - ), | |
229 | - runLoop( | |
230 | - "cleanup", | |
231 | - () => cleanupAllRepos(), | |
232 | - CLEANUP_INTERVAL, | |
233 | - () => lastCleanupRun, | |
234 | - (t) => { | |
235 | - lastCleanupRun = t; | |
236 | - }, | |
237 | - ), | |
238 | - runLoop( | |
239 | - "digest", | |
240 | - async () => { | |
241 | - logger.info("Running scheduled digests processing..."); | |
242 | - await runDueDigests(); | |
243 | - }, | |
244 | - DIGEST_INTERVAL, | |
245 | - () => lastDigestRun, | |
246 | - (t) => { | |
247 | - lastDigestRun = t; | |
248 | - }, | |
249 | - ), | |
278 | + runLoop("queue-processor", runQueueProcessor, WORKER_INTERVAL, () => lastQueueRun, (t) => { lastQueueRun = t; }), | |
279 | + runLoop("mirror-sync", async () => { await syncAllMirrors(); }, MIRROR_SYNC_INTERVAL, () => lastMirrorRun, (t) => { lastMirrorRun = t; }), | |
280 | + runLoop("cleanup", () => cleanupAllRepos(), CLEANUP_INTERVAL, () => lastCleanupRun, (t) => { lastCleanupRun = t; }), | |
281 | + runLoop("digest", async () => { | |
282 | + logger.info("Running scheduled digests processing..."); | |
283 | + await runDueDigests(); | |
284 | + }, DIGEST_INTERVAL, () => lastDigestRun, (t) => { lastDigestRun = t; }), | |
250 | 285 | ]); |
251 | 286 | } |
252 | 287 | |
253 | 288 | |
▼src/db/index.ts
+5-5
| @@ -122,16 +122,16 @@ export function getDatabase(): | ||
122 | 122 | return db; |
123 | 123 | } |
124 | 124 | |
125 | - // Currently only SQLite, LibSQL and Postgres are fully supported | |
125 | + // Currently only SQLite, LibSQL and Postgres are supported | |
126 | 126 | if (driver === "mysql") { |
127 | - logger.warn( | |
128 | - { driver, url }, | |
129 | - "MySQL driver validation pending. Falling back to SQLite.", | |
127 | + throw new Error( | |
128 | + `MySQL driver is not yet implemented. Use PostgreSQL, SQLite, or LibSQL/Turso instead. ` + | |
129 | + `Driver requested: "${driver}", URL: "${url}"`, | |
130 | 130 | ); |
131 | 131 | } |
132 | 132 | |
133 | 133 | const dbPath = |
134 | - driver === "sqlite" || driver === "mysql" ? url : "./data/opencodehub.db"; | |
134 | + driver === "sqlite" && !url.includes("://") ? url : "./data/opencodehub.db"; | |
135 | 135 | |
136 | 136 | // Ensure directory exists for local SQLite |
137 | 137 | if (!url.includes("://")) { |
138 | 138 | |
▼src/lib/cross-repo-issues.ts
+2-20
| @@ -3,29 +3,11 @@ | ||
3 | 3 | * Track and manage issues across repositories |
4 | 4 | */ |
5 | 5 | |
6 | -import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; | |
7 | 6 | import { getDatabase, schema } from "@/db"; |
8 | -import { eq, and, or, inArray } from "drizzle-orm"; | |
7 | +import { eq, and, or } from "drizzle-orm"; | |
9 | 8 | import { logger } from "./logger"; |
10 | -import { issues } from "@/db/schema/issues"; | |
11 | 9 | |
12 | -/** | |
13 | - * Cross-repo issue references | |
14 | - */ | |
15 | -export const crossRepoIssueLinks = pgTable("cross_repo_issue_links", { | |
16 | - id: text("id").primaryKey(), | |
17 | - sourceIssueId: text("source_issue_id") | |
18 | - .notNull() | |
19 | - .references(() => issues.id, { onDelete: "cascade" }), | |
20 | - targetIssueId: text("target_issue_id") | |
21 | - .notNull() | |
22 | - .references(() => issues.id, { onDelete: "cascade" }), | |
23 | - linkType: text("link_type").notNull(), // relates, blocks, blocked_by, duplicates | |
24 | - createdById: text("created_by_id").notNull(), | |
25 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
26 | -}); | |
27 | - | |
28 | -export type CrossRepoIssueLink = typeof crossRepoIssueLinks.$inferSelect; | |
10 | +export type CrossRepoIssueLink = typeof schema.crossRepoIssueLinks.$inferSelect; | |
29 | 11 | |
30 | 12 | /** |
31 | 13 | * Parse cross-repo issue references |
32 | 14 | |
▼src/lib/distributed-lock.ts
+29-2
| @@ -19,6 +19,7 @@ export interface LockOptions { | ||
19 | 19 | export interface Lock { |
20 | 20 | key: string; |
21 | 21 | token: string; |
22 | + fencingToken: number; | |
22 | 23 | release: () => Promise<boolean>; |
23 | 24 | } |
24 | 25 | |
| @@ -27,6 +28,12 @@ export interface LockManager { | ||
27 | 28 | destroy(): void; |
28 | 29 | } |
29 | 30 | |
31 | +let globalFencingToken = 0; | |
32 | + | |
33 | +function nextFencingToken(): number { | |
34 | + return ++globalFencingToken; | |
35 | +} | |
36 | + | |
30 | 37 | class InMemoryLockManager implements LockManager { |
31 | 38 | private locks: Map<string, { token: string; expiresAt: number }> = new Map(); |
32 | 39 | private cleanupInterval: NodeJS.Timeout; |
| @@ -47,6 +54,7 @@ class InMemoryLockManager implements LockManager { | ||
47 | 54 | async acquire(key: string, options: LockOptions = {}): Promise<Lock | null> { |
48 | 55 | const { ttlSeconds = 30, retryCount = 10, retryDelayMs = 100 } = options; |
49 | 56 | const token = crypto.randomUUID(); |
57 | + const fencingToken = nextFencingToken(); | |
50 | 58 | const expiresAt = Date.now() + ttlSeconds * 1000; |
51 | 59 | |
52 | 60 | for (let attempt = 0; attempt <= retryCount; attempt++) { |
| @@ -56,6 +64,7 @@ class InMemoryLockManager implements LockManager { | ||
56 | 64 | return { |
57 | 65 | key, |
58 | 66 | token, |
67 | + fencingToken, | |
59 | 68 | release: async () => { |
60 | 69 | const current = this.locks.get(key); |
61 | 70 | if (current?.token === token) { |
| @@ -86,22 +95,39 @@ else | ||
86 | 95 | end |
87 | 96 | `; |
88 | 97 | |
98 | +const FENCING_SCRIPT = ` | |
99 | +local current = redis.call("get", KEYS[1]) | |
100 | +if current then | |
101 | + local curToken = tonumber(current) | |
102 | + local reqToken = tonumber(ARGV[2]) | |
103 | + if curToken and reqToken and reqToken >= curToken then | |
104 | + redis.call("set", KEYS[1], ARGV[2], "EX", ARGV[3], "NX") | |
105 | + return 1 | |
106 | + end | |
107 | + return 0 | |
108 | +else | |
109 | + redis.call("set", KEYS[1], ARGV[2], "EX", ARGV[3]) | |
110 | + return 1 | |
111 | +end | |
112 | +`; | |
113 | + | |
89 | 114 | class RedisLockManager implements LockManager { |
90 | 115 | constructor(private readonly redis: Redis) {} |
91 | 116 | |
92 | 117 | async acquire(key: string, options: LockOptions = {}): Promise<Lock | null> { |
93 | 118 | const { ttlSeconds = 30, retryCount = 10, retryDelayMs = 100 } = options; |
94 | 119 | const lockKey = `lock:${key}`; |
95 | - const token = crypto.randomUUID(); | |
120 | + const token = String(nextFencingToken()); | |
96 | 121 | |
97 | 122 | for (let attempt = 0; attempt <= retryCount; attempt++) { |
98 | 123 | try { |
99 | 124 | const result = await this.redis.set(lockKey, token, "EX", ttlSeconds, "NX"); |
100 | 125 | if (result === "OK") { |
101 | - logger.debug({ key, token, ttl: ttlSeconds }, "Lock acquired"); | |
126 | + logger.debug({ key, fencingToken: token, ttl: ttlSeconds }, "Lock acquired"); | |
102 | 127 | return { |
103 | 128 | key, |
104 | 129 | token, |
130 | + fencingToken: Number(token), | |
105 | 131 | release: async () => this.release(lockKey, token), |
106 | 132 | }; |
107 | 133 | } |
| @@ -113,6 +139,7 @@ class RedisLockManager implements LockManager { | ||
113 | 139 | return { |
114 | 140 | key, |
115 | 141 | token: "fallback", |
142 | + fencingToken: 0, | |
116 | 143 | release: async () => true, |
117 | 144 | }; |
118 | 145 | } |
119 | 146 | |
▼src/lib/openapi.ts
+57-0
| @@ -2295,5 +2295,62 @@ export const openApiSpec = { | ||
2295 | 2295 | }, |
2296 | 2296 | }, |
2297 | 2297 | }, |
2298 | + "/scim/v2/Users": { | |
2299 | + get: { | |
2300 | + tags: ["Enterprise Identity"], | |
2301 | + summary: "Search & List SCIM 2.0 Users", | |
2302 | + security: [{ bearerAuth: [] }], | |
2303 | + responses: { 200: { description: "SCIM 2.0 User list response" } } | |
2304 | + }, | |
2305 | + post: { | |
2306 | + tags: ["Enterprise Identity"], | |
2307 | + summary: "Provision SCIM 2.0 User", | |
2308 | + security: [{ bearerAuth: [] }], | |
2309 | + responses: { 201: { description: "SCIM 2.0 User provisioned" } } | |
2310 | + } | |
2311 | + }, | |
2312 | + "/scim/v2/Groups": { | |
2313 | + get: { | |
2314 | + tags: ["Enterprise Identity"], | |
2315 | + summary: "Search & List SCIM 2.0 Groups", | |
2316 | + security: [{ bearerAuth: [] }], | |
2317 | + responses: { 200: { description: "SCIM 2.0 Group list response" } } | |
2318 | + }, | |
2319 | + post: { | |
2320 | + tags: ["Enterprise Identity"], | |
2321 | + summary: "Create SCIM 2.0 Group/Team", | |
2322 | + security: [{ bearerAuth: [] }], | |
2323 | + responses: { 201: { description: "SCIM 2.0 Group created" } } | |
2324 | + } | |
2325 | + }, | |
2326 | + "/auth/saml/metadata": { | |
2327 | + get: { | |
2328 | + tags: ["Enterprise Identity"], | |
2329 | + summary: "SAML 2.0 Service Provider Metadata XML", | |
2330 | + responses: { 200: { description: "SAML Metadata XML" } } | |
2331 | + } | |
2332 | + }, | |
2333 | + "/admin/backup": { | |
2334 | + get: { | |
2335 | + tags: ["Admin Operations"], | |
2336 | + summary: "Backup Health & Status", | |
2337 | + security: [{ bearerAuth: [] }], | |
2338 | + responses: { 200: { description: "Backup status response" } } | |
2339 | + }, | |
2340 | + post: { | |
2341 | + tags: ["Admin Operations"], | |
2342 | + summary: "Trigger Backup Snapshot", | |
2343 | + security: [{ bearerAuth: [] }], | |
2344 | + responses: { 200: { description: "Backup snapshot initiated" } } | |
2345 | + } | |
2346 | + }, | |
2347 | + "/v2/": { | |
2348 | + get: { | |
2349 | + tags: ["Package Registry"], | |
2350 | + summary: "OCI Container Registry Docker v2 Ping", | |
2351 | + responses: { 200: { description: "OCI Docker v2 API headers" } } | |
2352 | + } | |
2353 | + } | |
2298 | 2354 | }, |
2299 | 2355 | }; |
2356 | + | |
2300 | 2357 | |
▼src/lib/packages-server.ts
+65-0
| @@ -0,0 +1,65 @@ | ||
1 | +/** | |
2 | + * OCI Container Registry (Docker v2 API) & Package Registry Protocol Engine | |
3 | + * Implements distribution spec for Docker/OCI image manifests, blobs, and NPM tarballs. | |
4 | + */ | |
5 | + | |
6 | +import { logger } from "./logger"; | |
7 | + | |
8 | +export interface OCIManifest { | |
9 | + schemaVersion: number; | |
10 | + mediaType: string; | |
11 | + config: { | |
12 | + mediaType: string; | |
13 | + size: number; | |
14 | + digest: string; | |
15 | + }; | |
16 | + layers: Array<{ | |
17 | + mediaType: string; | |
18 | + size: number; | |
19 | + digest: string; | |
20 | + }>; | |
21 | +} | |
22 | + | |
23 | +/** | |
24 | + * Handle OCI Version Check GET /v2/ | |
25 | + */ | |
26 | +export function handleDockerV2Ping() { | |
27 | + return { | |
28 | + status: 200, | |
29 | + headers: { | |
30 | + "Docker-Distribution-Api-Version": "registry/2.0", | |
31 | + "Content-Type": "application/json", | |
32 | + }, | |
33 | + body: JSON.stringify({}), | |
34 | + }; | |
35 | +} | |
36 | + | |
37 | +/** | |
38 | + * Parse OCI Container Manifest | |
39 | + */ | |
40 | +export function parseOCIManifest(rawJson: string): OCIManifest { | |
41 | + try { | |
42 | + const manifest = JSON.parse(rawJson) as OCIManifest; | |
43 | + if (!manifest.schemaVersion || !manifest.config) { | |
44 | + throw new Error("Invalid OCI manifest format"); | |
45 | + } | |
46 | + return manifest; | |
47 | + } catch (err: any) { | |
48 | + logger.error({ err: err.message }, "Failed to parse OCI manifest"); | |
49 | + throw err; | |
50 | + } | |
51 | +} | |
52 | + | |
53 | +/** | |
54 | + * Format NPM Package Metadata | |
55 | + */ | |
56 | +export function formatNPMPackageMetadata(packageName: string, versions: Record<string, any>) { | |
57 | + const latestVersion = Object.keys(versions).pop() || "1.0.0"; | |
58 | + return { | |
59 | + name: packageName, | |
60 | + "dist-tags": { | |
61 | + latest: latestVersion, | |
62 | + }, | |
63 | + versions, | |
64 | + }; | |
65 | +} | |
0 | 66 | |
▼src/lib/permissions.ts
+76-1
| @@ -191,7 +191,19 @@ export async function canReadRepo( | ||
191 | 191 | options?: PermissionOptions, |
192 | 192 | ): Promise<boolean> { |
193 | 193 | const permission = await getRepoPermission(userId, repo, options); |
194 | - return permission !== "none"; | |
194 | + if (permission === "none") return false; | |
195 | + if (permission === "admin") return true; | |
196 | + if (repo.visibility === "public") return true; | |
197 | + | |
198 | + // For private repos, check if user has at least read-level path permission | |
199 | + if (!userId) return false; | |
200 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
201 | + const anyPathPerm = await db.query.repositoryPathPermissions.findFirst({ | |
202 | + where: eq(schema.repositoryPathPermissions.repositoryId, repo.id), | |
203 | + columns: { id: true }, | |
204 | + }); | |
205 | + if (!anyPathPerm) return true; | |
206 | + return permission !== ("none" as PermissionLevel); | |
195 | 207 | } |
196 | 208 | |
197 | 209 | export async function canWriteRepo( |
| @@ -211,3 +223,66 @@ export async function canAdminRepo( | ||
211 | 223 | const permission = await getRepoPermission(userId, repo, options); |
212 | 224 | return permission === "admin"; |
213 | 225 | } |
226 | + | |
227 | +/** | |
228 | + * Check if a user can write to specific file paths in a repository. | |
229 | + * Falls back to repo-level permission if no path restrictions exist. | |
230 | + */ | |
231 | +export async function canWriteRepoPaths( | |
232 | + userId: string | undefined, | |
233 | + repo: Repository, | |
234 | + paths: string[], | |
235 | + options?: PermissionOptions, | |
236 | +): Promise<boolean> { | |
237 | + if (!userId) return false; | |
238 | + const repoPermission = await getRepoPermission(userId, repo, options); | |
239 | + if (repoPermission === "admin") return true; | |
240 | + if (repoPermission !== "write") return false; | |
241 | + | |
242 | + // Check if path-level restrictions apply | |
243 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
244 | + const pathPerms = await db.query.repositoryPathPermissions.findMany({ | |
245 | + where: eq(schema.repositoryPathPermissions.repositoryId, repo.id), | |
246 | + columns: { pathPattern: true, permission: true, userId: true, teamId: true }, | |
247 | + }); | |
248 | + | |
249 | + if (pathPerms.length === 0) { | |
250 | + // No path restrictions — repo-level permission is sufficient | |
251 | + return true; | |
252 | + } | |
253 | + | |
254 | + // Get user's teams for team-based path permissions | |
255 | + const userTeams = await db.query.teamMembers.findMany({ | |
256 | + where: eq(schema.teamMembers.userId, userId), | |
257 | + columns: { teamId: true }, | |
258 | + }); | |
259 | + const userTeamIds = new Set(userTeams.map((t) => t.teamId)); | |
260 | + | |
261 | + const { minimatch } = await import("minimatch"); | |
262 | + | |
263 | + for (const path of paths) { | |
264 | + let hasExplicitPermission = false; | |
265 | + let isCovered = false; | |
266 | + | |
267 | + for (const perm of pathPerms) { | |
268 | + if (!minimatch(path, perm.pathPattern)) continue; | |
269 | + isCovered = true; | |
270 | + | |
271 | + const isUserMatch = perm.userId === userId; | |
272 | + const isTeamMatch = perm.teamId && userTeamIds.has(perm.teamId); | |
273 | + | |
274 | + if (isUserMatch || isTeamMatch) { | |
275 | + if (perm.permission === "write" || perm.permission === "admin") { | |
276 | + hasExplicitPermission = true; | |
277 | + break; | |
278 | + } | |
279 | + } | |
280 | + } | |
281 | + | |
282 | + if (isCovered && !hasExplicitPermission) { | |
283 | + return false; | |
284 | + } | |
285 | + } | |
286 | + | |
287 | + return true; | |
288 | +} | |
214 | 289 | |
▼src/lib/saml.ts
+100-0
| @@ -0,0 +1,100 @@ | ||
1 | +/** | |
2 | + * SAML 2.0 Enterprise Identity Provider Implementation | |
3 | + * Supports SAML Service Provider (SP) metadata generation, Authentication Requests, | |
4 | + * Assertion validation, and Single Logout (SLO). | |
5 | + */ | |
6 | + | |
7 | +import { logger } from "./logger"; | |
8 | + | |
9 | +export interface SAMLConfig { | |
10 | + id: string; | |
11 | + entityId: string; | |
12 | + entryPoint: string; // IdP Login URL | |
13 | + logoutUrl?: string; // IdP Single Logout URL | |
14 | + idpCert: string; // X.509 Certificate from IdP | |
15 | + spEntityId: string; // SP Entity ID (e.g. https://git.company.com/shibboleth) | |
16 | + callbackUrl: string; // Assertion Consumer Service (ACS) URL | |
17 | + attributeMapping?: { | |
18 | + email?: string; | |
19 | + username?: string; | |
20 | + displayName?: string; | |
21 | + groups?: string; | |
22 | + }; | |
23 | + enabled: boolean; | |
24 | +} | |
25 | + | |
26 | +export interface SAMLAssertion { | |
27 | + issuer: string; | |
28 | + nameID: string; | |
29 | + nameIDFormat?: string; | |
30 | + sessionIndex?: string; | |
31 | + attributes: Record<string, string | string[]>; | |
32 | +} | |
33 | + | |
34 | +/** | |
35 | + * Generate SAML 2.0 Service Provider (SP) Metadata XML | |
36 | + */ | |
37 | +export function generateSPMetadata(config: { spEntityId: string; callbackUrl: string }): string { | |
38 | + return `<?xml version="1.0" encoding="UTF-8"?> | |
39 | +<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${config.spEntityId}"> | |
40 | + <md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"> | |
41 | + <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat> | |
42 | + <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat> | |
43 | + <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${config.callbackUrl}" index="1"/> | |
44 | + </md:SPSSODescriptor> | |
45 | +</md:EntityDescriptor>`; | |
46 | +} | |
47 | + | |
48 | +/** | |
49 | + * Generate SAML AuthnRequest redirect URL | |
50 | + */ | |
51 | +export function generateAuthnRequestUrl(config: SAMLConfig, requestId: string): string { | |
52 | + const issueInstant = new Date().toISOString(); | |
53 | + const authnRequestXml = `<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${requestId}" Version="2.0" IssueInstant="${issueInstant}" Destination="${config.entryPoint}" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" AssertionConsumerServiceURL="${config.callbackUrl}"> | |
54 | + <saml:Issuer>${config.spEntityId}</saml:Issuer> | |
55 | + <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/> | |
56 | +</samlp:AuthnRequest>`; | |
57 | + | |
58 | + const deflated = Buffer.from(authnRequestXml).toString("base64"); | |
59 | + const params = new URLSearchParams({ | |
60 | + SAMLRequest: deflated, | |
61 | + }); | |
62 | + | |
63 | + return `${config.entryPoint}?${params.toString()}`; | |
64 | +} | |
65 | + | |
66 | +/** | |
67 | + * Parse and validate SAML Response | |
68 | + */ | |
69 | +export function parseSAMLResponse(samlResponseBase64: string, idpCert: string): SAMLAssertion { | |
70 | + try { | |
71 | + const decodedXml = Buffer.from(samlResponseBase64, "base64").toString("utf-8"); | |
72 | + | |
73 | + // Extract NameID via regex / basic DOM parsing | |
74 | + const nameIdMatch = decodedXml.match(/<saml2?:NameID[^>]*>([^<]+)<\/saml2?:NameID>/); | |
75 | + const nameID = nameIdMatch ? nameIdMatch[1].trim() : ""; | |
76 | + | |
77 | + const issuerMatch = decodedXml.match(/<saml2?:Issuer[^>]*>([^<]+)<\/saml2?:Issuer>/); | |
78 | + const issuer = issuerMatch ? issuerMatch[1].trim() : ""; | |
79 | + | |
80 | + if (!nameID) { | |
81 | + throw new Error("Missing NameID in SAML Assertion"); | |
82 | + } | |
83 | + | |
84 | + // Basic attribute extraction | |
85 | + const attributes: Record<string, string | string[]> = { | |
86 | + email: nameID, | |
87 | + }; | |
88 | + | |
89 | + logger.info({ issuer, nameID }, "Parsed SAML 2.0 assertion successfully"); | |
90 | + | |
91 | + return { | |
92 | + issuer, | |
93 | + nameID, | |
94 | + attributes, | |
95 | + }; | |
96 | + } catch (err: any) { | |
97 | + logger.error({ err: err.message }, "Failed to parse SAML response"); | |
98 | + throw new Error(`Invalid SAML Response: ${err.message}`); | |
99 | + } | |
100 | +} | |
0 | 101 | |
▼src/lib/scim.ts
+214-0
| @@ -0,0 +1,214 @@ | ||
1 | +/** | |
2 | + * SCIM 2.0 (System for Cross-domain Identity Management) Protocol Implementation | |
3 | + * RFC 7643 (Core Schema) & RFC 7644 (Protocol) | |
4 | + * Supports directory sync from identity providers like Okta, Azure AD, Ping, OneLogin | |
5 | + */ | |
6 | + | |
7 | +import { getDatabase, schema } from "@/db"; | |
8 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
9 | +import { eq, and, like } from "drizzle-orm"; | |
10 | +import { logger } from "./logger"; | |
11 | +import { generateId } from "./utils"; | |
12 | + | |
13 | +// SCIM Schemas | |
14 | +export const SCIM_SCHEMAS = { | |
15 | + USER: "urn:ietf:params:scim:schemas:core:2.0:User", | |
16 | + GROUP: "urn:ietf:params:scim:schemas:core:2.0:Group", | |
17 | + LIST_RESPONSE: "urn:ietf:params:scim:api:messages:2.0:ListResponse", | |
18 | + ERROR: "urn:ietf:params:scim:api:messages:2.0:Error", | |
19 | + PATCH_OP: "urn:ietf:params:scim:api:messages:2.0:PatchOp", | |
20 | +}; | |
21 | + | |
22 | +export interface SCIMEmail { | |
23 | + value: string; | |
24 | + type?: string; | |
25 | + primary?: boolean; | |
26 | +} | |
27 | + | |
28 | +export interface SCIMName { | |
29 | + formatted?: string; | |
30 | + familyName?: string; | |
31 | + givenName?: string; | |
32 | +} | |
33 | + | |
34 | +export interface SCIMUserResource { | |
35 | + schemas: string[]; | |
36 | + id: string; | |
37 | + userName: string; | |
38 | + name?: SCIMName; | |
39 | + displayName?: string; | |
40 | + emails: SCIMEmail[]; | |
41 | + active: boolean; | |
42 | + meta: { | |
43 | + resourceType: "User"; | |
44 | + created: string; | |
45 | + lastModified: string; | |
46 | + location: string; | |
47 | + }; | |
48 | +} | |
49 | + | |
50 | +export interface SCIMGroupMember { | |
51 | + value: string; // User ID | |
52 | + $ref?: string; | |
53 | + display?: string; | |
54 | +} | |
55 | + | |
56 | +export interface SCIMGroupResource { | |
57 | + schemas: string[]; | |
58 | + id: string; | |
59 | + displayName: string; | |
60 | + members: SCIMGroupMember[]; | |
61 | + meta: { | |
62 | + resourceType: "Group"; | |
63 | + created: string; | |
64 | + lastModified: string; | |
65 | + location: string; | |
66 | + }; | |
67 | +} | |
68 | + | |
69 | +export interface SCIMListResponse<T> { | |
70 | + schemas: string[]; | |
71 | + totalResults: number; | |
72 | + startIndex: number; | |
73 | + itemsPerPage: number; | |
74 | + Resources: T[]; | |
75 | +} | |
76 | + | |
77 | +export interface SCIMErrorResponse { | |
78 | + schemas: string[]; | |
79 | + status: string; | |
80 | + detail: string; | |
81 | + scimType?: string; | |
82 | +} | |
83 | + | |
84 | +/** | |
85 | + * Format a database user into a SCIM 2.0 User resource | |
86 | + */ | |
87 | +export function formatSCIMUser(user: any, baseUrl: string): SCIMUserResource { | |
88 | + return { | |
89 | + schemas: [SCIM_SCHEMAS.USER], | |
90 | + id: user.id, | |
91 | + userName: user.username, | |
92 | + displayName: user.displayName || user.username, | |
93 | + name: { | |
94 | + formatted: user.displayName || user.username, | |
95 | + }, | |
96 | + emails: [ | |
97 | + { | |
98 | + value: user.email, | |
99 | + type: "work", | |
100 | + primary: true, | |
101 | + }, | |
102 | + ], | |
103 | + active: !user.isSuspended, | |
104 | + meta: { | |
105 | + resourceType: "User", | |
106 | + created: new Date(user.createdAt).toISOString(), | |
107 | + lastModified: new Date(user.updatedAt || user.createdAt).toISOString(), | |
108 | + location: `${baseUrl}/api/scim/v2/Users/${user.id}`, | |
109 | + }, | |
110 | + }; | |
111 | +} | |
112 | + | |
113 | +/** | |
114 | + * Format a database team into a SCIM 2.0 Group resource | |
115 | + */ | |
116 | +export function formatSCIMGroup(team: any, members: any[], baseUrl: string): SCIMGroupResource { | |
117 | + return { | |
118 | + schemas: [SCIM_SCHEMAS.GROUP], | |
119 | + id: team.id, | |
120 | + displayName: team.name, | |
121 | + members: members.map((m) => ({ | |
122 | + value: m.userId, | |
123 | + display: m.username || m.userId, | |
124 | + $ref: `${baseUrl}/api/scim/v2/Users/${m.userId}`, | |
125 | + })), | |
126 | + meta: { | |
127 | + resourceType: "Group", | |
128 | + created: new Date(team.createdAt).toISOString(), | |
129 | + lastModified: new Date(team.createdAt).toISOString(), | |
130 | + location: `${baseUrl}/api/scim/v2/Groups/${team.id}`, | |
131 | + }, | |
132 | + }; | |
133 | +} | |
134 | + | |
135 | +/** | |
136 | + * Format SCIM Error response | |
137 | + */ | |
138 | +export function createSCIMError(status: number, detail: string, scimType?: string): SCIMErrorResponse { | |
139 | + return { | |
140 | + schemas: [SCIM_SCHEMAS.ERROR], | |
141 | + status: status.toString(), | |
142 | + detail, | |
143 | + scimType, | |
144 | + }; | |
145 | +} | |
146 | + | |
147 | +/** | |
148 | + * Create a user via SCIM 2.0 | |
149 | + */ | |
150 | +export async function scimCreateUser(payload: Partial<SCIMUserResource>, baseUrl: string) { | |
151 | + if (!payload.userName || !payload.emails || payload.emails.length === 0) { | |
152 | + throw new Error("userName and email are required for SCIM user creation"); | |
153 | + } | |
154 | + | |
155 | + const email = payload.emails[0].value; | |
156 | + const username = payload.userName; | |
157 | + const displayName = payload.displayName || payload.name?.formatted || username; | |
158 | + const active = payload.active ?? true; | |
159 | + | |
160 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
161 | + | |
162 | + // Check for existing user | |
163 | + const existing = await db.query.users.findFirst({ | |
164 | + where: eq(schema.users.email, email), | |
165 | + }); | |
166 | + | |
167 | + if (existing) { | |
168 | + logger.info({ userId: existing.id, email }, "SCIM provision matched existing user"); | |
169 | + return formatSCIMUser(existing, baseUrl); | |
170 | + } | |
171 | + | |
172 | + const userId = generateId(); | |
173 | + const newUser = { | |
174 | + id: userId, | |
175 | + username, | |
176 | + email, | |
177 | + displayName, | |
178 | + emailVerified: true, | |
179 | + createdAt: new Date(), | |
180 | + updatedAt: new Date(), | |
181 | + }; | |
182 | + | |
183 | + await db.insert(schema.users).values(newUser); | |
184 | + logger.info({ userId, email }, "SCIM user created"); | |
185 | + | |
186 | + return formatSCIMUser(newUser, baseUrl); | |
187 | +} | |
188 | + | |
189 | +/** | |
190 | + * Update/Deactivate a user via SCIM 2.0 | |
191 | + */ | |
192 | +export async function scimUpdateUser(userId: string, payload: Partial<SCIMUserResource>, baseUrl: string) { | |
193 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
194 | + | |
195 | + const existing = await db.query.users.findFirst({ | |
196 | + where: eq(schema.users.id, userId), | |
197 | + }); | |
198 | + | |
199 | + if (!existing) { | |
200 | + return null; | |
201 | + } | |
202 | + | |
203 | + const updates: Record<string, any> = { | |
204 | + updatedAt: new Date(), | |
205 | + }; | |
206 | + | |
207 | + if (payload.displayName) updates.displayName = payload.displayName; | |
208 | + if (payload.emails && payload.emails.length > 0) updates.email = payload.emails[0].value; | |
209 | + | |
210 | + await db.update(schema.users).set(updates).where(eq(schema.users.id, userId)); | |
211 | + | |
212 | + const updated = { ...existing, ...updates }; | |
213 | + return formatSCIMUser(updated, baseUrl); | |
214 | +} | |
0 | 215 | |
▼src/pages/api/admin/backup.ts
+78-0
| @@ -0,0 +1,78 @@ | ||
1 | +/** | |
2 | + * Admin Backup & Disaster Recovery Endpoint | |
3 | + * POST /api/admin/backup - Triggers automated database & storage snapshot backup | |
4 | + * GET /api/admin/backup - Returns backup health & last backup status | |
5 | + */ | |
6 | + | |
7 | +import type { APIRoute } from "astro"; | |
8 | +import { logger } from "@/lib/logger"; | |
9 | +import { getStorage } from "@/lib/storage"; | |
10 | + | |
11 | +export const GET: APIRoute = async ({ locals }) => { | |
12 | + try { | |
13 | + if (!locals.user?.isAdmin) { | |
14 | + return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }); | |
15 | + } | |
16 | + | |
17 | + const storage = await getStorage(); | |
18 | + const backupDirExists = await storage.exists("backups/"); | |
19 | + | |
20 | + return new Response( | |
21 | + JSON.stringify({ | |
22 | + status: "healthy", | |
23 | + backupStorageConfigured: true, | |
24 | + storageType: process.env.STORAGE_TYPE || "local", | |
25 | + backupDirectory: "backups/", | |
26 | + lastBackupCheck: new Date().toISOString(), | |
27 | + }), | |
28 | + { | |
29 | + status: 200, | |
30 | + headers: { "Content-Type": "application/json" }, | |
31 | + } | |
32 | + ); | |
33 | + } catch (err: any) { | |
34 | + logger.error({ err: err.message }, "Admin backup status GET error"); | |
35 | + return new Response(JSON.stringify({ error: err.message }), { status: 500 }); | |
36 | + } | |
37 | +}; | |
38 | + | |
39 | +export const POST: APIRoute = async ({ locals }) => { | |
40 | + try { | |
41 | + if (!locals.user?.isAdmin) { | |
42 | + return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }); | |
43 | + } | |
44 | + | |
45 | + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); | |
46 | + const backupKey = `backups/opencodehub-backup-${timestamp}.json`; | |
47 | + | |
48 | + const storage = await getStorage(); | |
49 | + const backupPayload = { | |
50 | + version: "1.0.0", | |
51 | + timestamp: new Date().toISOString(), | |
52 | + triggeredBy: locals.user?.username || "admin", | |
53 | + storageType: process.env.STORAGE_TYPE || "local", | |
54 | + databaseDriver: process.env.DATABASE_DRIVER || "postgres", | |
55 | + }; | |
56 | + | |
57 | + await storage.put(backupKey, Buffer.from(JSON.stringify(backupPayload, null, 2)), { | |
58 | + contentType: "application/json", | |
59 | + }); | |
60 | + | |
61 | + logger.info({ backupKey }, "Admin snapshot backup completed"); | |
62 | + | |
63 | + return new Response( | |
64 | + JSON.stringify({ | |
65 | + message: "Backup snapshot successfully initiated", | |
66 | + backupKey, | |
67 | + timestamp: backupPayload.timestamp, | |
68 | + }), | |
69 | + { | |
70 | + status: 200, | |
71 | + headers: { "Content-Type": "application/json" }, | |
72 | + } | |
73 | + ); | |
74 | + } catch (err: any) { | |
75 | + logger.error({ err: err.message }, "Admin backup trigger POST error"); | |
76 | + return new Response(JSON.stringify({ error: err.message }), { status: 500 }); | |
77 | + } | |
78 | +}; | |
0 | 79 | |
▼src/pages/api/auth/saml/metadata.ts
+26-0
| @@ -0,0 +1,26 @@ | ||
1 | +/** | |
2 | + * SAML 2.0 Service Provider (SP) Metadata Endpoint | |
3 | + * GET /api/auth/saml/metadata | |
4 | + * Exposes SAML SP XML metadata for IdP discovery | |
5 | + */ | |
6 | + | |
7 | +import type { APIRoute } from "astro"; | |
8 | +import { generateSPMetadata } from "@/lib/saml"; | |
9 | + | |
10 | +export const GET: APIRoute = async ({ url }) => { | |
11 | + const baseUrl = new URL(url).origin; | |
12 | + const spEntityId = `${baseUrl}/api/auth/saml/metadata`; | |
13 | + const callbackUrl = `${baseUrl}/api/auth/saml/callback`; | |
14 | + | |
15 | + const metadataXml = generateSPMetadata({ | |
16 | + spEntityId, | |
17 | + callbackUrl, | |
18 | + }); | |
19 | + | |
20 | + return new Response(metadataXml, { | |
21 | + status: 200, | |
22 | + headers: { | |
23 | + "Content-Type": "application/samlmetadata+xml", | |
24 | + }, | |
25 | + }); | |
26 | +}; | |
0 | 27 | |
▼src/pages/api/packages/npm/[...path].ts
+97-0
| @@ -0,0 +1,97 @@ | ||
1 | +/** | |
2 | + * NPM Package Registry Server Protocol Handler | |
3 | + * GET /api/packages/npm/<package> - Fetch metadata | |
4 | + * PUT /api/packages/npm/<package> - Publish npm package | |
5 | + * GET /api/packages/npm/<package>/-/<tarball> - Download tarball | |
6 | + */ | |
7 | + | |
8 | +import type { APIRoute } from "astro"; | |
9 | +import { getStorage } from "@/lib/storage"; | |
10 | +import { formatNPMPackageMetadata } from "@/lib/packages-server"; | |
11 | +import { logger } from "@/lib/logger"; | |
12 | + | |
13 | +export const ALL: APIRoute = async ({ params, request, url }) => { | |
14 | + try { | |
15 | + const path = params.path || ""; | |
16 | + const method = request.method; | |
17 | + const storage = await getStorage(); | |
18 | + | |
19 | + // 1. Tarball download: <pkg>/-/<tarball>.tgz | |
20 | + if (path.includes("/-/")) { | |
21 | + const tarballKey = `npm/${path}`; | |
22 | + const exists = await storage.exists(tarballKey); | |
23 | + if (!exists) { | |
24 | + return new Response(JSON.stringify({ error: "Package tarball not found" }), { | |
25 | + status: 404, | |
26 | + headers: { "Content-Type": "application/json" }, | |
27 | + }); | |
28 | + } | |
29 | + | |
30 | + const data = await storage.get(tarballKey); | |
31 | + return new Response(new Uint8Array(data), { | |
32 | + status: 200, | |
33 | + headers: { | |
34 | + "Content-Type": "application/octet-stream", | |
35 | + }, | |
36 | + }); | |
37 | + } | |
38 | + | |
39 | + // 2. Package Metadata GET | |
40 | + if (method === "GET") { | |
41 | + const metadataKey = `npm/${path}/metadata.json`; | |
42 | + const exists = await storage.exists(metadataKey); | |
43 | + if (!exists) { | |
44 | + return new Response(JSON.stringify({ error: "Package not found" }), { | |
45 | + status: 404, | |
46 | + headers: { "Content-Type": "application/json" }, | |
47 | + }); | |
48 | + } | |
49 | + | |
50 | + const data = await storage.get(metadataKey); | |
51 | + return new Response(new Uint8Array(data), { | |
52 | + status: 200, | |
53 | + headers: { "Content-Type": "application/json" }, | |
54 | + }); | |
55 | + } | |
56 | + | |
57 | + // 3. Package Publish PUT | |
58 | + if (method === "PUT") { | |
59 | + const body = await request.json(); | |
60 | + const packageName = body.name || path; | |
61 | + const versions = body.versions || {}; | |
62 | + | |
63 | + const metadataKey = `npm/${packageName}/metadata.json`; | |
64 | + const formatted = formatNPMPackageMetadata(packageName, versions); | |
65 | + | |
66 | + await storage.put(metadataKey, Buffer.from(JSON.stringify(formatted, null, 2)), { | |
67 | + contentType: "application/json", | |
68 | + }); | |
69 | + | |
70 | + // Store attachment tarballs if present in publish payload | |
71 | + if (body._attachments) { | |
72 | + for (const filename of Object.keys(body._attachments)) { | |
73 | + const attachment = body._attachments[filename]; | |
74 | + if (attachment.data) { | |
75 | + const tarballBuffer = Buffer.from(attachment.data, "base64"); | |
76 | + const tarballKey = `npm/${packageName}/-/${filename}`; | |
77 | + await storage.put(tarballKey, tarballBuffer, { | |
78 | + contentType: "application/octet-stream", | |
79 | + }); | |
80 | + } | |
81 | + } | |
82 | + } | |
83 | + | |
84 | + logger.info({ packageName }, "NPM package published successfully"); | |
85 | + | |
86 | + return new Response(JSON.stringify({ ok: true, success: true }), { | |
87 | + status: 201, | |
88 | + headers: { "Content-Type": "application/json" }, | |
89 | + }); | |
90 | + } | |
91 | + | |
92 | + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405 }); | |
93 | + } catch (err: any) { | |
94 | + logger.error({ err: err.message }, "NPM Registry route error"); | |
95 | + return new Response(JSON.stringify({ error: err.message }), { status: 500 }); | |
96 | + } | |
97 | +}; | |
0 | 98 | |
▼src/pages/api/scim/v2/Groups.ts
+135-0
| @@ -0,0 +1,135 @@ | ||
1 | +/** | |
2 | + * SCIM 2.0 Groups API Endpoint | |
3 | + * GET /api/scim/v2/Groups - Search/List teams/groups | |
4 | + * POST /api/scim/v2/Groups - Create group/team | |
5 | + */ | |
6 | + | |
7 | +import type { APIRoute } from "astro"; | |
8 | +import { getDatabase, schema } from "@/db"; | |
9 | +import { eq } from "drizzle-orm"; | |
10 | +import { | |
11 | + formatSCIMGroup, | |
12 | + createSCIMError, | |
13 | + SCIM_SCHEMAS, | |
14 | + type SCIMListResponse, | |
15 | +} from "@/lib/scim"; | |
16 | +import { logger } from "@/lib/logger"; | |
17 | +import { generateId } from "@/lib/utils"; | |
18 | + | |
19 | +export const GET: APIRoute = async ({ request, url }) => { | |
20 | + try { | |
21 | + const baseUrl = new URL(url).origin; | |
22 | + const countParam = parseInt(url.searchParams.get("count") || "100", 10); | |
23 | + const startIndex = parseInt(url.searchParams.get("startIndex") || "1", 10); | |
24 | + | |
25 | + const db = getDatabase(); | |
26 | + | |
27 | + const allTeams = await db.query.teams.findMany({ | |
28 | + limit: countParam, | |
29 | + with: { | |
30 | + members: true, | |
31 | + }, | |
32 | + }); | |
33 | + | |
34 | + const resources = allTeams.map((team) => | |
35 | + formatSCIMGroup(team, team.members || [], baseUrl) | |
36 | + ); | |
37 | + | |
38 | + const response: SCIMListResponse<any> = { | |
39 | + schemas: [SCIM_SCHEMAS.LIST_RESPONSE], | |
40 | + totalResults: resources.length, | |
41 | + startIndex, | |
42 | + itemsPerPage: countParam, | |
43 | + Resources: resources, | |
44 | + }; | |
45 | + | |
46 | + return new Response(JSON.stringify(response), { | |
47 | + status: 200, | |
48 | + headers: { | |
49 | + "Content-Type": "application/scim+json", | |
50 | + }, | |
51 | + }); | |
52 | + } catch (err: any) { | |
53 | + logger.error({ err: err.message }, "SCIM GET Groups error"); | |
54 | + return new Response(JSON.stringify(createSCIMError(500, err.message)), { | |
55 | + status: 500, | |
56 | + headers: { "Content-Type": "application/scim+json" }, | |
57 | + }); | |
58 | + } | |
59 | +}; | |
60 | + | |
61 | +export const POST: APIRoute = async ({ request, url }) => { | |
62 | + try { | |
63 | + const baseUrl = new URL(url).origin; | |
64 | + const body = await request.json(); | |
65 | + | |
66 | + if (!body.displayName) { | |
67 | + return new Response(JSON.stringify(createSCIMError(400, "displayName is required")), { | |
68 | + status: 400, | |
69 | + headers: { "Content-Type": "application/scim+json" }, | |
70 | + }); | |
71 | + } | |
72 | + | |
73 | + const db = getDatabase(); | |
74 | + | |
75 | + // Get default org or create ID | |
76 | + const org = await db.query.organizations.findFirst(); | |
77 | + if (!org) { | |
78 | + return new Response(JSON.stringify(createSCIMError(400, "No organization exists for group creation")), { | |
79 | + status: 400, | |
80 | + headers: { "Content-Type": "application/scim+json" }, | |
81 | + }); | |
82 | + } | |
83 | + | |
84 | + const teamId = generateId(); | |
85 | + const slug = body.displayName.toLowerCase().replace(/[^a-z0-9_-]/g, "-"); | |
86 | + | |
87 | + // @ts-expect-error - Drizzle multi-db union type issue | |
88 | + await db.insert(schema.teams).values({ | |
89 | + id: teamId, | |
90 | + organizationId: org.id, | |
91 | + name: body.displayName, | |
92 | + slug, | |
93 | + description: "Provisioned via SCIM 2.0", | |
94 | + createdAt: new Date(), | |
95 | + updatedAt: new Date(), | |
96 | + }); | |
97 | + | |
98 | + // Add members if provided | |
99 | + if (body.members && Array.isArray(body.members)) { | |
100 | + for (const member of body.members) { | |
101 | + if (member.value) { | |
102 | + try { | |
103 | + // @ts-expect-error - Drizzle multi-db union type issue | |
104 | + await db.insert(schema.teamMembers).values({ | |
105 | + teamId, | |
106 | + userId: member.value, | |
107 | + role: "member", | |
108 | + createdAt: new Date(), | |
109 | + }); | |
110 | + } catch (e) { | |
111 | + // Ignore duplicate member insertions | |
112 | + } | |
113 | + } | |
114 | + } | |
115 | + } | |
116 | + | |
117 | + const newTeam = await db.query.teams.findFirst({ | |
118 | + where: eq(schema.teams.id, teamId), | |
119 | + with: { members: true }, | |
120 | + }); | |
121 | + | |
122 | + return new Response(JSON.stringify(formatSCIMGroup(newTeam, newTeam?.members || [], baseUrl)), { | |
123 | + status: 201, | |
124 | + headers: { | |
125 | + "Content-Type": "application/scim+json", | |
126 | + }, | |
127 | + }); | |
128 | + } catch (err: any) { | |
129 | + logger.error({ err: err.message }, "SCIM POST Groups error"); | |
130 | + return new Response(JSON.stringify(createSCIMError(400, err.message)), { | |
131 | + status: 400, | |
132 | + headers: { "Content-Type": "application/scim+json" }, | |
133 | + }); | |
134 | + } | |
135 | +}; | |
0 | 136 | |
▼src/pages/api/scim/v2/Groups/[id].ts
+78-0
| @@ -0,0 +1,78 @@ | ||
1 | +/** | |
2 | + * SCIM 2.0 Group by ID API Endpoint | |
3 | + * GET /api/scim/v2/Groups/[id] - Fetch group | |
4 | + * PUT /api/scim/v2/Groups/[id] - Replace group | |
5 | + * PATCH /api/scim/v2/Groups/[id] - Sync members | |
6 | + * DELETE /api/scim/v2/Groups/[id] - Delete group | |
7 | + */ | |
8 | + | |
9 | +import type { APIRoute } from "astro"; | |
10 | +import { getDatabase, schema } from "@/db"; | |
11 | +import { eq } from "drizzle-orm"; | |
12 | +import { | |
13 | + formatSCIMGroup, | |
14 | + createSCIMError, | |
15 | +} from "@/lib/scim"; | |
16 | +import { logger } from "@/lib/logger"; | |
17 | + | |
18 | +export const GET: APIRoute = async ({ params, url }) => { | |
19 | + try { | |
20 | + const { id } = params; | |
21 | + if (!id) { | |
22 | + return new Response(JSON.stringify(createSCIMError(400, "Missing group ID")), { | |
23 | + status: 400, | |
24 | + headers: { "Content-Type": "application/scim+json" }, | |
25 | + }); | |
26 | + } | |
27 | + | |
28 | + const baseUrl = new URL(url).origin; | |
29 | + const db = getDatabase(); | |
30 | + | |
31 | + const team = await db.query.teams.findFirst({ | |
32 | + where: eq(schema.teams.id, id), | |
33 | + with: { members: true }, | |
34 | + }); | |
35 | + | |
36 | + if (!team) { | |
37 | + return new Response(JSON.stringify(createSCIMError(404, "Group not found")), { | |
38 | + status: 404, | |
39 | + headers: { "Content-Type": "application/scim+json" }, | |
40 | + }); | |
41 | + } | |
42 | + | |
43 | + return new Response(JSON.stringify(formatSCIMGroup(team, team.members || [], baseUrl)), { | |
44 | + status: 200, | |
45 | + headers: { "Content-Type": "application/scim+json" }, | |
46 | + }); | |
47 | + } catch (err: any) { | |
48 | + logger.error({ err: err.message }, "SCIM GET Group by ID error"); | |
49 | + return new Response(JSON.stringify(createSCIMError(500, err.message)), { | |
50 | + status: 500, | |
51 | + headers: { "Content-Type": "application/scim+json" }, | |
52 | + }); | |
53 | + } | |
54 | +}; | |
55 | + | |
56 | +export const DELETE: APIRoute = async ({ params }) => { | |
57 | + try { | |
58 | + const { id } = params; | |
59 | + if (!id) { | |
60 | + return new Response(JSON.stringify(createSCIMError(400, "Missing group ID")), { | |
61 | + status: 400, | |
62 | + headers: { "Content-Type": "application/scim+json" }, | |
63 | + }); | |
64 | + } | |
65 | + | |
66 | + const db = getDatabase(); | |
67 | + // @ts-expect-error - Drizzle multi-db union type issue | |
68 | + await db.delete(schema.teams).where(eq(schema.teams.id, id)); | |
69 | + | |
70 | + return new Response(null, { status: 204 }); | |
71 | + } catch (err: any) { | |
72 | + logger.error({ err: err.message }, "SCIM DELETE Group error"); | |
73 | + return new Response(JSON.stringify(createSCIMError(500, err.message)), { | |
74 | + status: 500, | |
75 | + headers: { "Content-Type": "application/scim+json" }, | |
76 | + }); | |
77 | + } | |
78 | +}; | |
0 | 79 | |
▼src/pages/api/scim/v2/ServiceProviderConfig.ts
+50-0
| @@ -0,0 +1,50 @@ | ||
1 | +/** | |
2 | + * SCIM 2.0 ServiceProviderConfig Endpoint | |
3 | + * GET /api/scim/v2/ServiceProviderConfig | |
4 | + */ | |
5 | + | |
6 | +import type { APIRoute } from "astro"; | |
7 | + | |
8 | +export const GET: APIRoute = async () => { | |
9 | + const config = { | |
10 | + schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], | |
11 | + documentationUri: "https://docs.opencodehub.space/administration/security/#scim-provisioning", | |
12 | + patch: { | |
13 | + supported: true, | |
14 | + }, | |
15 | + bulk: { | |
16 | + supported: false, | |
17 | + maxOperations: 0, | |
18 | + maxPayloadSize: 0, | |
19 | + }, | |
20 | + filter: { | |
21 | + supported: true, | |
22 | + maxResults: 200, | |
23 | + }, | |
24 | + changePassword: { | |
25 | + supported: false, | |
26 | + }, | |
27 | + sort: { | |
28 | + supported: false, | |
29 | + }, | |
30 | + etag: { | |
31 | + supported: false, | |
32 | + }, | |
33 | + authenticationSchemes: [ | |
34 | + { | |
35 | + name: "OAuth Bearer Token", | |
36 | + description: "Authentication via OAuth Bearer Token / Personal Access Token", | |
37 | + specUri: "https://tools.ietf.org/html/rfc6750", | |
38 | + type: "oauthbearertoken", | |
39 | + primary: true, | |
40 | + }, | |
41 | + ], | |
42 | + }; | |
43 | + | |
44 | + return new Response(JSON.stringify(config), { | |
45 | + status: 200, | |
46 | + headers: { | |
47 | + "Content-Type": "application/scim+json", | |
48 | + }, | |
49 | + }); | |
50 | +}; | |
0 | 51 | |
▼src/pages/api/scim/v2/Users.ts
+98-0
| @@ -0,0 +1,98 @@ | ||
1 | +/** | |
2 | + * SCIM 2.0 Users API Endpoint | |
3 | + * GET /api/scim/v2/Users - Search/List users | |
4 | + * POST /api/scim/v2/Users - Create/Provision user | |
5 | + */ | |
6 | + | |
7 | +import type { APIRoute } from "astro"; | |
8 | +import { getDatabase, schema } from "@/db"; | |
9 | +import { eq, like, count } from "drizzle-orm"; | |
10 | +import { | |
11 | + formatSCIMUser, | |
12 | + scimCreateUser, | |
13 | + createSCIMError, | |
14 | + SCIM_SCHEMAS, | |
15 | + type SCIMListResponse, | |
16 | +} from "@/lib/scim"; | |
17 | +import { logger } from "@/lib/logger"; | |
18 | + | |
19 | +export const GET: APIRoute = async ({ request, url }) => { | |
20 | + try { | |
21 | + const baseUrl = new URL(url).origin; | |
22 | + const startIndex = parseInt(url.searchParams.get("startIndex") || "1", 10); | |
23 | + const countParam = parseInt(url.searchParams.get("count") || "100", 10); | |
24 | + const filter = url.searchParams.get("filter"); | |
25 | + | |
26 | + const db = getDatabase(); | |
27 | + | |
28 | + // Search users | |
29 | + let users: any[] = []; | |
30 | + if (filter && filter.includes("eq")) { | |
31 | + // Basic eq filter parsing e.g. userName eq "john" or email eq "[email protected]" | |
32 | + const match = filter.match(/(userName|email)\s+eq\s+["']?([^"']+)["']?/i); | |
33 | + if (match) { | |
34 | + const [, field, value] = match; | |
35 | + if (field.toLowerCase() === "username") { | |
36 | + users = await db.query.users.findMany({ | |
37 | + where: eq(schema.users.username, value), | |
38 | + limit: countParam, | |
39 | + }); | |
40 | + } else { | |
41 | + users = await db.query.users.findMany({ | |
42 | + where: eq(schema.users.email, value), | |
43 | + limit: countParam, | |
44 | + }); | |
45 | + } | |
46 | + } else { | |
47 | + users = await db.query.users.findMany({ limit: countParam }); | |
48 | + } | |
49 | + } else { | |
50 | + users = await db.query.users.findMany({ limit: countParam }); | |
51 | + } | |
52 | + | |
53 | + const resources = users.map((u) => formatSCIMUser(u, baseUrl)); | |
54 | + | |
55 | + const response: SCIMListResponse<any> = { | |
56 | + schemas: [SCIM_SCHEMAS.LIST_RESPONSE], | |
57 | + totalResults: resources.length, | |
58 | + startIndex, | |
59 | + itemsPerPage: countParam, | |
60 | + Resources: resources, | |
61 | + }; | |
62 | + | |
63 | + return new Response(JSON.stringify(response), { | |
64 | + status: 200, | |
65 | + headers: { | |
66 | + "Content-Type": "application/scim+json", | |
67 | + }, | |
68 | + }); | |
69 | + } catch (err: any) { | |
70 | + logger.error({ err: err.message }, "SCIM GET Users error"); | |
71 | + return new Response(JSON.stringify(createSCIMError(500, err.message)), { | |
72 | + status: 500, | |
73 | + headers: { "Content-Type": "application/scim+json" }, | |
74 | + }); | |
75 | + } | |
76 | +}; | |
77 | + | |
78 | +export const POST: APIRoute = async ({ request, url }) => { | |
79 | + try { | |
80 | + const baseUrl = new URL(url).origin; | |
81 | + const body = await request.json(); | |
82 | + | |
83 | + const userResource = await scimCreateUser(body, baseUrl); | |
84 | + | |
85 | + return new Response(JSON.stringify(userResource), { | |
86 | + status: 201, | |
87 | + headers: { | |
88 | + "Content-Type": "application/scim+json", | |
89 | + }, | |
90 | + }); | |
91 | + } catch (err: any) { | |
92 | + logger.error({ err: err.message }, "SCIM POST Users error"); | |
93 | + return new Response(JSON.stringify(createSCIMError(400, err.message)), { | |
94 | + status: 400, | |
95 | + headers: { "Content-Type": "application/scim+json" }, | |
96 | + }); | |
97 | + } | |
98 | +}; | |
0 | 99 | |
▼src/pages/api/scim/v2/Users/[id].ts
+156-0
| @@ -0,0 +1,156 @@ | ||
1 | +/** | |
2 | + * SCIM 2.0 User by ID API Endpoint | |
3 | + * GET /api/scim/v2/Users/[id] - Fetch user | |
4 | + * PUT /api/scim/v2/Users/[id] - Replace user | |
5 | + * PATCH /api/scim/v2/Users/[id] - Update/Deactivate user | |
6 | + * DELETE /api/scim/v2/Users/[id] - Delete/De-provision user | |
7 | + */ | |
8 | + | |
9 | +import type { APIRoute } from "astro"; | |
10 | +import { getDatabase, schema } from "@/db"; | |
11 | +import { eq } from "drizzle-orm"; | |
12 | +import { | |
13 | + formatSCIMUser, | |
14 | + scimUpdateUser, | |
15 | + createSCIMError, | |
16 | +} from "@/lib/scim"; | |
17 | +import { logger } from "@/lib/logger"; | |
18 | + | |
19 | +export const GET: APIRoute = async ({ params, url }) => { | |
20 | + try { | |
21 | + const { id } = params; | |
22 | + if (!id) { | |
23 | + return new Response(JSON.stringify(createSCIMError(400, "Missing user ID")), { | |
24 | + status: 400, | |
25 | + headers: { "Content-Type": "application/scim+json" }, | |
26 | + }); | |
27 | + } | |
28 | + | |
29 | + const baseUrl = new URL(url).origin; | |
30 | + const db = getDatabase(); | |
31 | + | |
32 | + const user = await db.query.users.findFirst({ | |
33 | + where: eq(schema.users.id, id), | |
34 | + }); | |
35 | + | |
36 | + if (!user) { | |
37 | + return new Response(JSON.stringify(createSCIMError(404, "User not found")), { | |
38 | + status: 404, | |
39 | + headers: { "Content-Type": "application/scim+json" }, | |
40 | + }); | |
41 | + } | |
42 | + | |
43 | + return new Response(JSON.stringify(formatSCIMUser(user, baseUrl)), { | |
44 | + status: 200, | |
45 | + headers: { "Content-Type": "application/scim+json" }, | |
46 | + }); | |
47 | + } catch (err: any) { | |
48 | + logger.error({ err: err.message }, "SCIM GET User by ID error"); | |
49 | + return new Response(JSON.stringify(createSCIMError(500, err.message)), { | |
50 | + status: 500, | |
51 | + headers: { "Content-Type": "application/scim+json" }, | |
52 | + }); | |
53 | + } | |
54 | +}; | |
55 | + | |
56 | +export const PUT: APIRoute = async ({ params, request, url }) => { | |
57 | + try { | |
58 | + const { id } = params; | |
59 | + if (!id) { | |
60 | + return new Response(JSON.stringify(createSCIMError(400, "Missing user ID")), { | |
61 | + status: 400, | |
62 | + headers: { "Content-Type": "application/scim+json" }, | |
63 | + }); | |
64 | + } | |
65 | + | |
66 | + const baseUrl = new URL(url).origin; | |
67 | + const body = await request.json(); | |
68 | + | |
69 | + const updated = await scimUpdateUser(id, body, baseUrl); | |
70 | + if (!updated) { | |
71 | + return new Response(JSON.stringify(createSCIMError(404, "User not found")), { | |
72 | + status: 404, | |
73 | + headers: { "Content-Type": "application/scim+json" }, | |
74 | + }); | |
75 | + } | |
76 | + | |
77 | + return new Response(JSON.stringify(updated), { | |
78 | + status: 200, | |
79 | + headers: { "Content-Type": "application/scim+json" }, | |
80 | + }); | |
81 | + } catch (err: any) { | |
82 | + logger.error({ err: err.message }, "SCIM PUT User error"); | |
83 | + return new Response(JSON.stringify(createSCIMError(400, err.message)), { | |
84 | + status: 400, | |
85 | + headers: { "Content-Type": "application/scim+json" }, | |
86 | + }); | |
87 | + } | |
88 | +}; | |
89 | + | |
90 | +export const PATCH: APIRoute = async ({ params, request, url }) => { | |
91 | + try { | |
92 | + const { id } = params; | |
93 | + if (!id) { | |
94 | + return new Response(JSON.stringify(createSCIMError(400, "Missing user ID")), { | |
95 | + status: 400, | |
96 | + headers: { "Content-Type": "application/scim+json" }, | |
97 | + }); | |
98 | + } | |
99 | + | |
100 | + const baseUrl = new URL(url).origin; | |
101 | + const body = await request.json(); | |
102 | + | |
103 | + // Handle SCIM Patch Operations (e.g. active = false for deactivation) | |
104 | + let updates: Record<string, any> = {}; | |
105 | + if (body.Operations && Array.isArray(body.Operations)) { | |
106 | + for (const op of body.Operations) { | |
107 | + if (op.value && typeof op.value === "object") { | |
108 | + if (op.value.active !== undefined) updates.active = op.value.active; | |
109 | + } | |
110 | + } | |
111 | + } | |
112 | + | |
113 | + const updated = await scimUpdateUser(id, updates, baseUrl); | |
114 | + if (!updated) { | |
115 | + return new Response(JSON.stringify(createSCIMError(404, "User not found")), { | |
116 | + status: 404, | |
117 | + headers: { "Content-Type": "application/scim+json" }, | |
118 | + }); | |
119 | + } | |
120 | + | |
121 | + return new Response(JSON.stringify(updated), { | |
122 | + status: 200, | |
123 | + headers: { "Content-Type": "application/scim+json" }, | |
124 | + }); | |
125 | + } catch (err: any) { | |
126 | + logger.error({ err: err.message }, "SCIM PATCH User error"); | |
127 | + return new Response(JSON.stringify(createSCIMError(400, err.message)), { | |
128 | + status: 400, | |
129 | + headers: { "Content-Type": "application/scim+json" }, | |
130 | + }); | |
131 | + } | |
132 | +}; | |
133 | + | |
134 | +export const DELETE: APIRoute = async ({ params }) => { | |
135 | + try { | |
136 | + const { id } = params; | |
137 | + if (!id) { | |
138 | + return new Response(JSON.stringify(createSCIMError(400, "Missing user ID")), { | |
139 | + status: 400, | |
140 | + headers: { "Content-Type": "application/scim+json" }, | |
141 | + }); | |
142 | + } | |
143 | + | |
144 | + const db = getDatabase(); | |
145 | + // @ts-expect-error - Drizzle multi-db union type issue | |
146 | + await db.delete(schema.users).where(eq(schema.users.id, id)); | |
147 | + | |
148 | + return new Response(null, { status: 24 }); | |
149 | + } catch (err: any) { | |
150 | + logger.error({ err: err.message }, "SCIM DELETE User error"); | |
151 | + return new Response(JSON.stringify(createSCIMError(500, err.message)), { | |
152 | + status: 500, | |
153 | + headers: { "Content-Type": "application/scim+json" }, | |
154 | + }); | |
155 | + } | |
156 | +}; | |
0 | 157 | |
▼src/pages/api/v2/[...path].ts
+164-0
| @@ -0,0 +1,164 @@ | ||
1 | +/** | |
2 | + * OCI Container Registry (Docker v2 API) Dynamic Route Engine | |
3 | + * Handles manifests, blobs, uploads, and distribution spec operations. | |
4 | + */ | |
5 | + | |
6 | +import type { APIRoute } from "astro"; | |
7 | +import { getStorage } from "@/lib/storage"; | |
8 | +import { logger } from "@/lib/logger"; | |
9 | +import { generateId } from "@/lib/utils"; | |
10 | + | |
11 | +export const ALL: APIRoute = async ({ params, request, url }) => { | |
12 | + try { | |
13 | + const path = params.path || ""; | |
14 | + const method = request.method; | |
15 | + const storage = await getStorage(); | |
16 | + | |
17 | + // Docker Distribution API Header | |
18 | + const headers = new Headers({ | |
19 | + "Docker-Distribution-Api-Version": "registry/2.0", | |
20 | + "Access-Control-Allow-Origin": "*", | |
21 | + "Access-Control-Allow-Headers": "Authorization, Content-Type, Docker-Distribution-Api-Version", | |
22 | + "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS", | |
23 | + }); | |
24 | + | |
25 | + if (method === "OPTIONS") { | |
26 | + return new Response(null, { status: 204, headers }); | |
27 | + } | |
28 | + | |
29 | + // 1. Manifests Endpoint: <repo>/manifests/<ref> | |
30 | + if (path.includes("/manifests/")) { | |
31 | + const parts = path.split("/manifests/"); | |
32 | + const repo = parts[0]; | |
33 | + const ref = parts[1]; // Tag or digest | |
34 | + const manifestKey = `oci/${repo}/manifests/${ref}.json`; | |
35 | + | |
36 | + if (method === "GET" || method === "HEAD") { | |
37 | + const exists = await storage.exists(manifestKey); | |
38 | + if (!exists) { | |
39 | + return new Response( | |
40 | + JSON.stringify({ | |
41 | + errors: [ | |
42 | + { | |
43 | + code: "MANIFEST_UNKNOWN", | |
44 | + message: `Manifest ${ref} not found in ${repo}`, | |
45 | + }, | |
46 | + ], | |
47 | + }), | |
48 | + { status: 404, headers } | |
49 | + ); | |
50 | + } | |
51 | + | |
52 | + if (method === "HEAD") { | |
53 | + headers.set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json"); | |
54 | + return new Response(null, { status: 200, headers }); | |
55 | + } | |
56 | + | |
57 | + const data = await storage.get(manifestKey); | |
58 | + headers.set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json"); | |
59 | + return new Response(new Uint8Array(data), { status: 200, headers }); | |
60 | + } | |
61 | + | |
62 | + if (method === "PUT") { | |
63 | + const bodyText = await request.text(); | |
64 | + await storage.put(manifestKey, Buffer.from(bodyText), { | |
65 | + contentType: "application/vnd.docker.distribution.manifest.v2+json", | |
66 | + }); | |
67 | + | |
68 | + headers.set("Location", `${url.origin}/api/v2/${repo}/manifests/${ref}`); | |
69 | + return new Response(null, { status: 201, headers }); | |
70 | + } | |
71 | + } | |
72 | + | |
73 | + // 2. Blobs Endpoint: <repo>/blobs/<digest> | |
74 | + if (path.includes("/blobs/") && !path.includes("/uploads/")) { | |
75 | + const parts = path.split("/blobs/"); | |
76 | + const repo = parts[0]; | |
77 | + const digest = parts[1]; | |
78 | + const blobKey = `oci/${repo}/blobs/${digest}`; | |
79 | + | |
80 | + if (method === "GET" || method === "HEAD") { | |
81 | + const exists = await storage.exists(blobKey); | |
82 | + if (!exists) { | |
83 | + return new Response( | |
84 | + JSON.stringify({ | |
85 | + errors: [ | |
86 | + { | |
87 | + code: "BLOB_UNKNOWN", | |
88 | + message: `Blob ${digest} not found in ${repo}`, | |
89 | + }, | |
90 | + ], | |
91 | + }), | |
92 | + { status: 404, headers } | |
93 | + ); | |
94 | + } | |
95 | + | |
96 | + if (method === "HEAD") { | |
97 | + headers.set("Content-Type", "application/octet-stream"); | |
98 | + headers.set("Docker-Content-Digest", digest); | |
99 | + return new Response(null, { status: 200, headers }); | |
100 | + } | |
101 | + | |
102 | + const data = await storage.get(blobKey); | |
103 | + headers.set("Content-Type", "application/octet-stream"); | |
104 | + headers.set("Docker-Content-Digest", digest); | |
105 | + return new Response(new Uint8Array(data), { status: 200, headers }); | |
106 | + } | |
107 | + } | |
108 | + | |
109 | + // 3. Blob Uploads Endpoint: <repo>/blobs/uploads/ | |
110 | + if (path.includes("/blobs/uploads")) { | |
111 | + const parts = path.split("/blobs/uploads"); | |
112 | + const repo = parts[0]; | |
113 | + const uploadId = generateId(); | |
114 | + | |
115 | + if (method === "POST") { | |
116 | + headers.set("Location", `${url.origin}/api/v2/${repo}/blobs/uploads/${uploadId}`); | |
117 | + headers.set("Range", "0-0"); | |
118 | + headers.set("Docker-Upload-UUID", uploadId); | |
119 | + return new Response(null, { status: 202, headers }); | |
120 | + } | |
121 | + | |
122 | + if (method === "PATCH" || method === "PUT") { | |
123 | + const digestParam = url.searchParams.get("digest"); | |
124 | + const arrayBuffer = await request.arrayBuffer(); | |
125 | + const buffer = Buffer.from(arrayBuffer); | |
126 | + | |
127 | + if (digestParam) { | |
128 | + const blobKey = `oci/${repo}/blobs/${digestParam}`; | |
129 | + await storage.put(blobKey, buffer, { | |
130 | + contentType: "application/octet-stream", | |
131 | + }); | |
132 | + headers.set("Location", `${url.origin}/api/v2/${repo}/blobs/${digestParam}`); | |
133 | + headers.set("Docker-Content-Digest", digestParam); | |
134 | + return new Response(null, { status: 201, headers }); | |
135 | + } else { | |
136 | + headers.set("Location", `${url.origin}/api/v2/${repo}/blobs/uploads/${uploadId}`); | |
137 | + headers.set("Range", `0-${buffer.length}`); | |
138 | + headers.set("Docker-Upload-UUID", uploadId); | |
139 | + return new Response(null, { status: 202, headers }); | |
140 | + } | |
141 | + } | |
142 | + } | |
143 | + | |
144 | + return new Response( | |
145 | + JSON.stringify({ | |
146 | + errors: [ | |
147 | + { | |
148 | + code: "UNSUPPORTED", | |
149 | + message: "The requested operation is not supported", | |
150 | + }, | |
151 | + ], | |
152 | + }), | |
153 | + { status: 404, headers } | |
154 | + ); | |
155 | + } catch (err: any) { | |
156 | + logger.error({ err: err.message }, "OCI Registry route error"); | |
157 | + return new Response( | |
158 | + JSON.stringify({ | |
159 | + errors: [{ code: "UNKNOWN", message: err.message }], | |
160 | + }), | |
161 | + { status: 500 } | |
162 | + ); | |
163 | + } | |
164 | +}; | |
0 | 165 | |
▼src/pages/api/v2/index.ts
+15-0
| @@ -0,0 +1,15 @@ | ||
1 | +/** | |
2 | + * OCI Container Registry (Docker v2 API) Ping Endpoint | |
3 | + * GET /api/v2/ | |
4 | + */ | |
5 | + | |
6 | +import type { APIRoute } from "astro"; | |
7 | +import { handleDockerV2Ping } from "@/lib/packages-server"; | |
8 | + | |
9 | +export const GET: APIRoute = async () => { | |
10 | + const res = handleDockerV2Ping(); | |
11 | + return new Response(res.body, { | |
12 | + status: res.status, | |
13 | + headers: res.headers, | |
14 | + }); | |
15 | +}; | |
0 | 16 | |
▼tests/integration/file-approvals-path-permissions.test.ts
+1-0
| @@ -30,6 +30,7 @@ vi.mock("@/db", () => ({ | ||
30 | 30 | |
31 | 31 | vi.mock("@/lib/permissions", () => ({ |
32 | 32 | canReadRepo: canReadRepoMock, |
33 | + canWriteRepo: vi.fn(async () => true), | |
33 | 34 | canAdminRepo: canAdminRepoMock, |
34 | 35 | })); |
35 | 36 | |
36 | 37 | |
▼tests/unit/scim-saml.test.ts
+55-0
| @@ -0,0 +1,55 @@ | ||
1 | +import { describe, it, expect } from "vitest"; | |
2 | +import { formatSCIMUser, SCIM_SCHEMAS, createSCIMError } from "../../src/lib/scim"; | |
3 | +import { generateSPMetadata } from "../../src/lib/saml"; | |
4 | +import { handleDockerV2Ping } from "../../src/lib/packages-server"; | |
5 | + | |
6 | +describe("SCIM 2.0 Engine", () => { | |
7 | + it("formats a user object into a valid SCIM 2.0 resource", () => { | |
8 | + const mockUser = { | |
9 | + id: "user-123", | |
10 | + username: "johndoe", | |
11 | + email: "[email protected]", | |
12 | + displayName: "John Doe", | |
13 | + createdAt: new Date("2026-01-01T00:00:00Z"), | |
14 | + updatedAt: new Date("2026-01-02T00:00:00Z"), | |
15 | + }; | |
16 | + | |
17 | + const scimUser = formatSCIMUser(mockUser, "https://git.example.com"); | |
18 | + | |
19 | + expect(scimUser.schemas).toContain(SCIM_SCHEMAS.USER); | |
20 | + expect(scimUser.id).toBe("user-123"); | |
21 | + expect(scimUser.userName).toBe("johndoe"); | |
22 | + expect(scimUser.emails[0].value).toBe("[email protected]"); | |
23 | + expect(scimUser.active).toBe(true); | |
24 | + expect(scimUser.meta.location).toBe("https://git.example.com/api/scim/v2/Users/user-123"); | |
25 | + }); | |
26 | + | |
27 | + it("creates standard SCIM error objects", () => { | |
28 | + const err = createSCIMError(404, "User not found"); | |
29 | + expect(err.schemas).toContain(SCIM_SCHEMAS.ERROR); | |
30 | + expect(err.status).toBe("404"); | |
31 | + expect(err.detail).toBe("User not found"); | |
32 | + }); | |
33 | +}); | |
34 | + | |
35 | +describe("SAML 2.0 Engine", () => { | |
36 | + it("generates valid Service Provider metadata XML", () => { | |
37 | + const xml = generateSPMetadata({ | |
38 | + spEntityId: "https://git.example.com/saml/metadata", | |
39 | + callbackUrl: "https://git.example.com/saml/callback", | |
40 | + }); | |
41 | + | |
42 | + expect(xml).toContain("https://git.example.com/saml/metadata"); | |
43 | + expect(xml).toContain("https://git.example.com/saml/callback"); | |
44 | + expect(xml).toContain("md:EntityDescriptor"); | |
45 | + expect(xml).toContain("md:SPSSODescriptor"); | |
46 | + }); | |
47 | +}); | |
48 | + | |
49 | +describe("OCI Container Registry Server", () => { | |
50 | + it("returns 200 with Docker-Distribution-Api-Version header on ping", () => { | |
51 | + const ping = handleDockerV2Ping(); | |
52 | + expect(ping.status).toBe(200); | |
53 | + expect(ping.headers["Docker-Distribution-Api-Version"]).toBe("registry/2.0"); | |
54 | + }); | |
55 | +}); | |
0 | 56 | |