Add backend API for projects & snapshots
Introduce full backend API surface for projects, snapshots and user pubkeys: controllers, DTOs, JPA entities, repositories, services, and tests. Add SecurityConfig improvements (JWT decoder, audience/issuer/timestamp validation and CORS), a GlobalExceptionHandler for consistent error responses, and remove a stray test call from BackendApplication. Also update README (architecture, docs, CLI/Frontend/Backend notes), add CLAUDE.md, VSCode settings, and multiple frontend/CLI Rust changes. Small housekeeping: add .DS_Store and test adjustments.
This commit is contained in:
1 parent
3d72c53262
commit
473e8e01fa
57 files changed
+3888
-281
No files matched your search
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
EnvSync is a self-hosted, zero-knowledge encrypted environment variable management system — think git for `.env` files. It consists of three components:
|
||||
|
||||
- **Backend** — Spring Boot 3 REST API (Java 21)
|
||||
- **Frontend** — Next.js 15 web dashboard (TypeScript)
|
||||
- **CLI** — Rust-based command-line tool (`cli/envsync-cli/`)
|
||||
|
||||
## Development Infrastructure
|
||||
|
||||
All dependent services run via Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d # PostgreSQL 15, Redis 7, MinIO, Keycloak (port 8180), pgAdmin (port 5050)
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend (Spring Boot + Maven)
|
||||
```bash
|
||||
cd backend
|
||||
./mvnw spring-boot:run # Dev server on port 8081
|
||||
./mvnw test # Run tests
|
||||
./mvnw clean package # Build JAR
|
||||
```
|
||||
|
||||
### Frontend (Next.js)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Dev server on port 3000
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Copy `frontend/.env.example` to `frontend/.env.local` and fill in Keycloak credentials before running.
|
||||
|
||||
### CLI (Rust)
|
||||
```bash
|
||||
cd cli/envsync-cli
|
||||
cargo build # Debug build
|
||||
cargo build --release # Release build
|
||||
cargo test # Run tests
|
||||
cargo clippy # Lint
|
||||
cargo fmt # Format
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Request Flow
|
||||
```
|
||||
CLI / Browser
|
||||
→ NGINX (TLS termination)
|
||||
→ Spring Boot API (port 8081)
|
||||
→ PostgreSQL (vaults, audit logs)
|
||||
→ Redis (pub/sub for real-time notifications)
|
||||
→ MinIO (encrypted vault snapshots, S3-compatible)
|
||||
→ Keycloak (OIDC/JWT validation, port 8180)
|
||||
```
|
||||
|
||||
### Authentication
|
||||
- **CLI** uses OAuth2 device code flow against Keycloak (`cli/envsync-cli/src/api/auth.rs`)
|
||||
- **Frontend** uses NextAuth with Keycloak OIDC provider (`frontend/src/lib/auth.ts`)
|
||||
- **Backend** is a Spring Security OAuth2 resource server — validates JWTs from Keycloak (`backend/src/main/java/.../SecurityConfig.java`)
|
||||
- Keycloak realm: `envsync`
|
||||
|
||||
### Zero-Knowledge Encryption (CLI)
|
||||
All encryption is client-side before data reaches the server:
|
||||
- Key derivation: Argon2id
|
||||
- Encryption: XChaCha20-Poly1305 (via `sodiumoxide`/libsodium)
|
||||
- Team sharing: vault key wrapped per-member with asymmetric encryption
|
||||
- Server only stores ciphertext
|
||||
|
||||
### CLI Commands
|
||||
Implemented in `cli/envsync-cli/src/commands/`: `init`, `push`, `pull`, `diff`, `log`, `set`, `unset`, `rollback`
|
||||
|
||||
### Backend Config
|
||||
Key settings in `backend/src/main/resources/application.properties`:
|
||||
- PostgreSQL: `localhost:5432` (user/pass: `admin`/`admin`)
|
||||
- Redis: `localhost:6379`
|
||||
- MinIO: `http://localhost:9000` (user/pass: `minioadmin`/`minioadmin`)
|
||||
- Keycloak JWKS: `http://localhost:8180/realms/envsync`
|
||||
@@ -7,11 +7,11 @@
|
||||
|
||||
[](https://nextjs.org/)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://redis.io/)
|
||||
[](https://min.io/)
|
||||
[](https://www.keycloak.org/)
|
||||
[](https://nginx.org/)
|
||||
[](https://www.docker.com/)
|
||||
|
||||
> Stop sending `.env` files over Slack. Stop wondering if your teammate has the latest secrets. EnvSync is a CLI + web dashboard that gives your team a single encrypted source of truth for environment variables — with diffing, versioning, and real-time notifications. **100% self-hosted. Zero cloud dependencies.**
|
||||
@@ -34,14 +34,15 @@ Environment variable drift causes bugs that are impossible to reproduce, broken
|
||||
## ✨ Features
|
||||
|
||||
- **CLI-first workflow** — `envsync push`, `envsync pull`, `envsync diff` — feels like git for your `.env`
|
||||
- **End-to-end encryption** — secrets encrypted client-side with `libsodium` before ever hitting the server
|
||||
- **Real-time notifications** — teammates get notified instantly via Redis pub/sub when variables change
|
||||
- **End-to-end encryption** — secrets encrypted client-side with `libsodium` (XSalsa20-Poly1305) before ever hitting the server
|
||||
- **Zero-knowledge by design** — server only stores ciphertext; no one can read your secrets without your passphrase
|
||||
- **Argon2id key derivation** — memory-hard KDF protects against brute force
|
||||
- **Versioned snapshots** — every push creates an immutable version; rollback to any previous state
|
||||
- **Environment scoping** — separate vaults for `development`, `staging`, `production`
|
||||
- **Audit log** — full history of who changed what, when, and from where
|
||||
- **`.env` diff viewer** — visual diff in the web dashboard showing exactly what changed between versions
|
||||
- **Org & project management** — multi-team support with role-based access control (Admin, Editor, Viewer)
|
||||
- **MinIO backups** — encrypted vault snapshots stored in self-hosted S3-compatible object storage
|
||||
- **Web dashboard** — manage projects, browse snapshot history, and copy CLI commands
|
||||
- **Audit trail** — every push records who pushed it and when
|
||||
- **Keycloak SSO** — enterprise-grade identity: OIDC login, MFA, LDAP, team provisioning
|
||||
- **100% self-hosted** — Docker Compose one-liner; nothing phones home
|
||||
|
||||
---
|
||||
|
||||
@@ -49,35 +50,30 @@ Environment variable drift causes bugs that are impossible to reproduce, broken
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ CLI Tool │
|
||||
│ envsync push / pull / diff │
|
||||
│ CLI Tool (Rust) │
|
||||
│ envsync push / pull / diff / rollback / log │
|
||||
│ XSalsa20-Poly1305 + Argon2id (client-side) │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│ HTTPS (via NGINX)
|
||||
│ E2E Encrypted Payload
|
||||
│ HTTPS · E2E Encrypted Payload
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ NGINX │
|
||||
│ Reverse Proxy / TLS termination │
|
||||
└──────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼──────────────────────────────────┐
|
||||
│ Spring Boot API │
|
||||
│ Auth · Vault CRUD · Audit Logging │
|
||||
│ Spring Boot API (port 8081) │
|
||||
│ Auth · Vault CRUD · Snapshot versioning │
|
||||
└──────┬──────────────────┬──────────────────┬────────────┘
|
||||
│ │ │
|
||||
┌──────▼──────┐ ┌───────▼──────┐ ┌──────▼──────┐
|
||||
│ PostgreSQL │ │ Redis │ │ MinIO │
|
||||
│ Vaults & │ │ Pub/Sub │ │ Encrypted │
|
||||
│ Audit Logs │ │ Notifications│ │ Backups │
|
||||
│ Snapshots │ │ (planned) │ │ Backups │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Keycloak │
|
||||
│ SSO · OIDC · Role Management · User Provisioning │
|
||||
│ Keycloak (port 8180) │
|
||||
│ SSO · OIDC · OAuth2 Device Code Flow │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ Next.js Dashboard │
|
||||
│ Web UI · Real-time updates · Diff viewer │
|
||||
│ Next.js Dashboard (port 3000) │
|
||||
│ Projects · Snapshot history · CLI guide │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -85,19 +81,17 @@ Environment variable drift causes bugs that are impossible to reproduce, broken
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
| Layer | Technology | Self-Hosted Replaces |
|
||||
| Layer | Technology | Version |
|
||||
|---|---|---|
|
||||
| **Frontend** | Next.js 15 (App Router) | — |
|
||||
| **Backend** | Spring Boot 3 (Java) | — |
|
||||
| **Database** | PostgreSQL 16 | AWS RDS |
|
||||
| **Cache / Pub-Sub** | Redis 7 | AWS ElastiCache / SQS |
|
||||
| **Object Storage** | MinIO | AWS S3 |
|
||||
| **Identity / Auth** | Keycloak (OIDC) | AWS IAM / Cognito |
|
||||
| **Reverse Proxy** | NGINX | AWS CloudFront |
|
||||
| **Encryption** | libsodium / TweetNaCl.js | AWS KMS |
|
||||
| **ORM** | Hibernate / Spring Data JPA | — |
|
||||
| **CLI** | Node.js (npm package) | — |
|
||||
| **Containerization** | Docker Compose | AWS ECS / Fargate |
|
||||
| **Frontend** | Next.js (App Router) | 15/16 |
|
||||
| **Backend** | Spring Boot | 3.x / Java 21 |
|
||||
| **CLI** | Rust | 2024 Edition |
|
||||
| **Database** | PostgreSQL | 15 |
|
||||
| **Cache / Pub-Sub** | Redis | 7 |
|
||||
| **Object Storage** | MinIO | latest |
|
||||
| **Identity / Auth** | Keycloak (OIDC) | latest |
|
||||
| **CLI Encryption** | libsodium (sodiumoxide) | XSalsa20-Poly1305 |
|
||||
| **Key Derivation** | Argon2id | 64 MB, 3 iterations |
|
||||
|
||||
---
|
||||
|
||||
@@ -106,8 +100,9 @@ Environment variable drift causes bugs that are impossible to reproduce, broken
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Java 21+
|
||||
- Node.js 20+
|
||||
- Java 21+ (for backend development)
|
||||
- Node.js 20+ (for frontend development)
|
||||
- Rust toolchain (for CLI development)
|
||||
|
||||
### 1. Clone & Start Infrastructure
|
||||
|
||||
@@ -115,54 +110,65 @@ Environment variable drift causes bugs that are impossible to reproduce, broken
|
||||
git clone https://github.com/yourusername/envsync.git
|
||||
cd envsync
|
||||
docker compose up -d
|
||||
# Starts: PostgreSQL, Redis, MinIO, Keycloak, NGINX
|
||||
# Starts: PostgreSQL (5432), Redis (6379), MinIO (9000/9001),
|
||||
# Keycloak (8180), pgAdmin (5050)
|
||||
```
|
||||
|
||||
### 2. Configure Keycloak
|
||||
|
||||
```
|
||||
Open http://localhost:8180
|
||||
Admin console → Create realm: "envsync"
|
||||
Create client: "envsync-api"
|
||||
Set redirect URIs: http://localhost:3000/*
|
||||
Admin credentials: admin / admin
|
||||
|
||||
1. Create realm: "envsync"
|
||||
2. Create client: "envsync-app"
|
||||
- Client authentication: ON
|
||||
- Valid redirect URIs: http://localhost:3000/*
|
||||
3. Create client: "envsync-app-cli"
|
||||
- OAuth 2.0 Device Authorization Grant: enabled
|
||||
4. Create a test user in the realm
|
||||
```
|
||||
|
||||
### 3. Configure MinIO
|
||||
|
||||
```
|
||||
Open http://localhost:9001 (MinIO Console)
|
||||
Create bucket: "envsync-backups"
|
||||
Generate access key → paste into backend .env
|
||||
```
|
||||
|
||||
### 4. Start the Backend
|
||||
### 3. Start the Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env # fill in Keycloak + MinIO credentials
|
||||
./mvnw spring-boot:run
|
||||
# API at http://localhost:8080
|
||||
# API at http://localhost:8081
|
||||
```
|
||||
|
||||
### 5. Start the Frontend
|
||||
The backend auto-creates tables via Hibernate DDL (`ddl-auto=update`). No migrations needed.
|
||||
|
||||
### 4. Start the Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env.local # fill in Keycloak client credentials
|
||||
npm install
|
||||
npm run dev
|
||||
# Dashboard at http://localhost:3000
|
||||
```
|
||||
|
||||
### 6. Install the CLI
|
||||
Required environment variables (`.env.local`):
|
||||
|
||||
```bash
|
||||
npm install -g envsync-cli
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=<random-32-char-string>
|
||||
KEYCLOAK_CLIENT_ID=envsync-frontend
|
||||
KEYCLOAK_CLIENT_SECRET=<from-keycloak-console>
|
||||
KEYCLOAK_ISSUER=http://localhost:8180/realms/envsync
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8081
|
||||
```
|
||||
|
||||
envsync login # OIDC flow via Keycloak
|
||||
envsync init # initialize in your project
|
||||
envsync push # push your .env to the vault
|
||||
envsync pull # pull latest secrets
|
||||
envsync diff # see what changed
|
||||
### 5. Build & Use the CLI
|
||||
|
||||
```bash
|
||||
cd cli/envsync-cli
|
||||
cargo build --release
|
||||
# Binary at: ./target/release/envsync
|
||||
|
||||
# Or add to PATH:
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
---
|
||||
@@ -170,51 +176,42 @@ envsync diff # see what changed
|
||||
## 📖 CLI Reference
|
||||
|
||||
```bash
|
||||
# Authentication
|
||||
envsync login # OAuth2 device code flow via Keycloak
|
||||
envsync logout # clear stored credentials
|
||||
envsync status # show auth status, project, token expiry
|
||||
|
||||
# Project initialization (run in your project directory)
|
||||
envsync init # interactive — prompts for name & env
|
||||
envsync init --project my-app --env development
|
||||
envsync push # push local .env → vault
|
||||
envsync pull # pull vault → local .env
|
||||
envsync diff # diff local vs vault
|
||||
envsync log # view audit log
|
||||
envsync set DATABASE_URL "postgres://..."
|
||||
|
||||
# Vault operations
|
||||
envsync push # encrypt & push local .env → vault
|
||||
envsync push -m "add stripe webhook" # with a commit message
|
||||
envsync push --force # skip conflict check
|
||||
envsync pull # pull latest snapshot → local .env
|
||||
envsync pull --force # skip confirmation prompt
|
||||
|
||||
# Inspection
|
||||
envsync diff # diff local .env vs latest remote snapshot
|
||||
envsync diff --show-values # show actual values (not masked)
|
||||
envsync log # snapshot history (last 20)
|
||||
envsync log --limit 50
|
||||
|
||||
# Mutation helpers
|
||||
envsync set DATABASE_URL "postgres://..." -m "update db url"
|
||||
envsync unset OLD_API_KEY
|
||||
envsync envs # list environments (dev/staging/prod)
|
||||
envsync history # version history of the vault
|
||||
envsync rollback --version 12 # restore a previous vault version
|
||||
|
||||
# History
|
||||
envsync rollback --version 12 # restore vault to v12 content (creates new snapshot)
|
||||
envsync rollback --version 12 --yes # skip confirmation
|
||||
```
|
||||
|
||||
---
|
||||
**Global flags:**
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
envsync/
|
||||
├── frontend/ # Next.js 15 dashboard
|
||||
│ ├── app/
|
||||
│ │ ├── (auth)/ # Keycloak OIDC login flow
|
||||
│ │ ├── dashboard/ # Overview & notifications
|
||||
│ │ ├── projects/ # Project vault management
|
||||
│ │ └── audit/ # Audit log viewer
|
||||
│ └── components/
|
||||
│ ├── DiffViewer/ # .env diff component
|
||||
│ └── VaultEditor/ # Secret CRUD UI
|
||||
│
|
||||
├── backend/ # Spring Boot API
|
||||
│ └── src/main/java/
|
||||
│ ├── auth/ # Keycloak + Spring Security OIDC
|
||||
│ ├── vault/ # Vault CRUD & versioning
|
||||
│ ├── audit/ # Audit log service
|
||||
│ ├── notification/ # Redis pub/sub notifications
|
||||
│ └── storage/ # MinIO backup service
|
||||
│
|
||||
├── cli/ # Node.js CLI tool
|
||||
│ └── src/
|
||||
│ ├── commands/ # push, pull, diff, set, unset, rollback
|
||||
│ └── crypto/ # libsodium client-side encryption
|
||||
│
|
||||
├── nginx/
|
||||
│ └── nginx.conf # Reverse proxy + TLS config
|
||||
│
|
||||
└── docker-compose.yml # Full self-hosted stack
|
||||
```bash
|
||||
envsync --api-url http://my-server:8081 <command>
|
||||
# or: export ENVSYNC_API_URL=http://my-server:8081
|
||||
```
|
||||
|
||||
---
|
||||
@@ -223,11 +220,87 @@ envsync/
|
||||
|
||||
EnvSync uses a **zero-knowledge encryption** model:
|
||||
|
||||
1. Secret key derived locally from your password using **Argon2**
|
||||
2. Secrets encrypted **before** leaving your machine using **XChaCha20-Poly1305**
|
||||
3. Server only ever stores ciphertext — it physically cannot read your secrets
|
||||
4. Team sharing via asymmetric encryption — each member's public key wraps the vault key
|
||||
5. Identity via **Keycloak** — supports MFA, LDAP federation, and social login out of the box
|
||||
1. **Key derivation** — your passphrase → 32-byte secret key via Argon2id (64 MB, 3 iterations, 1 thread)
|
||||
2. **Client-side encrypt** — `.env` → JSON → XSalsa20-Poly1305 encryption with a random nonce; ciphertext is base64-encoded
|
||||
3. **Server stores ciphertext only** — PostgreSQL never sees plaintext; the server physically cannot read your secrets
|
||||
4. **X25519 keypair** — generated on first login, private key encrypted with your master key; public key registered with the server for future team-sharing features
|
||||
5. **Identity via Keycloak** — supports MFA, LDAP federation, social login; CLI uses OAuth2 Device Code flow (no browser required)
|
||||
6. **Token auto-refresh** — CLI automatically refreshes expired access tokens using the stored refresh token
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
envsync/
|
||||
├── frontend/ # Next.js dashboard
|
||||
│ └── src/
|
||||
│ ├── app/
|
||||
│ │ ├── page.tsx # Landing page
|
||||
│ │ ├── dashbaord/ # Dashboard (authenticated)
|
||||
│ │ │ ├── page.tsx # Projects list
|
||||
│ │ │ ├── layout.tsx # Sidebar navigation
|
||||
│ │ │ └── projects/[id]/ # Project detail + snapshots
|
||||
│ │ └── api/auth/ # NextAuth Keycloak handler
|
||||
│ └── lib/
|
||||
│ ├── auth.ts # NextAuth options
|
||||
│ └── api.ts # Backend API client
|
||||
│
|
||||
├── backend/ # Spring Boot 3 REST API
|
||||
│ └── src/main/java/com/envsync/backend/
|
||||
│ ├── controller/ # ProjectController, SnapshotController, UserController
|
||||
│ ├── service/ # ProjectService, SnapshotService, UserPubkeyService
|
||||
│ ├── model/ # JPA entities: Project, Snapshot, UserPubkey
|
||||
│ ├── repository/ # Spring Data JPA repositories
|
||||
│ ├── dto/ # Request/Response DTOs
|
||||
│ ├── SecurityConfig.java # CORS + JWT + OAuth2 Resource Server
|
||||
│ └── GlobalExceptionHandler.java # Validation + error responses
|
||||
│
|
||||
└── cli/envsync-cli/ # Rust CLI
|
||||
└── src/
|
||||
├── commands/ # login, logout, init, push, pull, diff,
|
||||
│ # log, set, unset, rollback, status
|
||||
├── api/ # vault.rs (HTTP client), auth.rs (device code + refresh)
|
||||
├── crypto.rs # Argon2id + XSalsa20-Poly1305 + X25519
|
||||
├── config.rs # Config persistence + auto token refresh
|
||||
└── cli.rs # clap CLI definition
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Reference
|
||||
|
||||
All endpoints require `Authorization: Bearer <keycloak-token>`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/projects` | Create project |
|
||||
| `GET` | `/api/projects` | List my projects |
|
||||
| `GET` | `/api/projects/{id}` | Get project |
|
||||
| `DELETE` | `/api/projects/{id}` | Delete project |
|
||||
| `POST` | `/api/projects/{id}/snapshots` | Push snapshot |
|
||||
| `GET` | `/api/projects/{id}/snapshots` | List snapshots (`?limit=N`) |
|
||||
| `GET` | `/api/projects/{id}/snapshots/latest` | Get latest snapshot |
|
||||
| `GET` | `/api/projects/{id}/snapshots/{snapId}` | Get snapshot by ID |
|
||||
| `PUT` | `/api/users/me/pubkey` | Register X25519 public key |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# CLI tests (crypto roundtrips)
|
||||
cd cli/envsync-cli
|
||||
cargo test
|
||||
|
||||
# Backend unit tests (no database required)
|
||||
cd backend
|
||||
./mvnw test
|
||||
|
||||
# Frontend type-check + build
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,10 +8,6 @@ public class BackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BackendApplication.class, args);
|
||||
//call the test
|
||||
|
||||
test x = new test();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.envsync.backend;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
|
||||
Map<String, String> fieldErrors = ex.getBindingResult().getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(
|
||||
FieldError::getField,
|
||||
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value",
|
||||
(a, b) -> a
|
||||
));
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", Instant.now());
|
||||
body.put("status", HttpStatus.BAD_REQUEST.value());
|
||||
body.put("error", "Validation Failed");
|
||||
body.put("fields", fieldErrors);
|
||||
|
||||
return ResponseEntity.badRequest().body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleResponseStatus(ResponseStatusException ex) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", Instant.now());
|
||||
body.put("status", ex.getStatusCode().value());
|
||||
body.put("error", ex.getReason() != null ? ex.getReason() : ex.getMessage());
|
||||
return ResponseEntity.status(ex.getStatusCode()).body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", Instant.now());
|
||||
body.put("status", HttpStatus.FORBIDDEN.value());
|
||||
body.put("error", "Access denied");
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {
|
||||
logger.error("Unhandled exception", ex);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", Instant.now());
|
||||
body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
|
||||
body.put("error", "Internal server error");
|
||||
return ResponseEntity.internalServerError().body(body);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,98 @@
|
||||
package com.envsync.backend;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.*;
|
||||
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
|
||||
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
|
||||
private String jwkSetUri;
|
||||
|
||||
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
|
||||
private String issuerUri;
|
||||
|
||||
@Value("${app.security.jwt.audience}")
|
||||
private String audience;
|
||||
|
||||
@Value("${app.security.jwt.clock-skew-seconds:60}")
|
||||
private long clockSkewSeconds;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http.authorizeHttpRequests(auth -> auth
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer(oauth2 -> oauth2
|
||||
.jwt(Customizer.withDefaults())
|
||||
);
|
||||
http
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
// Stateless REST API — no CSRF needed (bearer-token auth, no cookies)
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer(oauth2 -> oauth2
|
||||
.jwt(jwt -> jwt.decoder(jwtDecoder()))
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder() {
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
|
||||
|
||||
// Timestamp validator with configurable clock skew
|
||||
JwtTimestampValidator timestampValidator =
|
||||
new JwtTimestampValidator(java.time.Duration.ofSeconds(clockSkewSeconds));
|
||||
|
||||
// Issuer validator
|
||||
OAuth2TokenValidator<Jwt> issuerValidator =
|
||||
new JwtIssuerValidator(issuerUri);
|
||||
|
||||
// Permissive audience validator — Keycloak may omit aud for some grant types
|
||||
OAuth2TokenValidator<Jwt> audienceValidator = token -> {
|
||||
List<String> audiences = token.getAudience();
|
||||
if (audiences == null || audiences.isEmpty() || audiences.contains(audience)) {
|
||||
return org.springframework.security.oauth2.core.OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
return org.springframework.security.oauth2.core.OAuth2TokenValidatorResult.failure(
|
||||
new org.springframework.security.oauth2.core.OAuth2Error("invalid_token",
|
||||
"Token audience does not include: " + audience, null));
|
||||
};
|
||||
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
timestampValidator, issuerValidator, audienceValidator));
|
||||
return decoder;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOriginPatterns(List.of(
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"https://*"
|
||||
));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
config.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", config);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.envsync.backend.controller;
|
||||
|
||||
import com.envsync.backend.dto.CreateProjectRequest;
|
||||
import com.envsync.backend.dto.ProjectResponse;
|
||||
import com.envsync.backend.model.Project;
|
||||
import com.envsync.backend.service.ProjectService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/projects")
|
||||
public class ProjectController {
|
||||
|
||||
private final ProjectService projectService;
|
||||
|
||||
public ProjectController(ProjectService projectService) {
|
||||
this.projectService = projectService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ProjectResponse create(
|
||||
@Valid @RequestBody CreateProjectRequest req,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
Project project = projectService.create(req.name(), req.environment(), jwt.getSubject());
|
||||
return ProjectResponse.from(project);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ProjectResponse> list(@AuthenticationPrincipal Jwt jwt) {
|
||||
return projectService.listForUser(jwt.getSubject())
|
||||
.stream()
|
||||
.map(ProjectResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ProjectResponse get(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
return ProjectResponse.from(projectService.getForUser(id, jwt.getSubject()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
projectService.delete(id, jwt.getSubject());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.envsync.backend.controller;
|
||||
|
||||
import com.envsync.backend.dto.PushSnapshotRequest;
|
||||
import com.envsync.backend.dto.SnapshotFullResponse;
|
||||
import com.envsync.backend.dto.SnapshotMetaResponse;
|
||||
import com.envsync.backend.model.Project;
|
||||
import com.envsync.backend.model.Snapshot;
|
||||
import com.envsync.backend.service.ProjectService;
|
||||
import com.envsync.backend.service.SnapshotService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/projects/{projectId}/snapshots")
|
||||
public class SnapshotController {
|
||||
|
||||
private final ProjectService projectService;
|
||||
private final SnapshotService snapshotService;
|
||||
|
||||
public SnapshotController(ProjectService projectService, SnapshotService snapshotService) {
|
||||
this.projectService = projectService;
|
||||
this.snapshotService = snapshotService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public SnapshotMetaResponse push(
|
||||
@PathVariable UUID projectId,
|
||||
@Valid @RequestBody PushSnapshotRequest req,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
Project project = projectService.getForUser(projectId, jwt.getSubject());
|
||||
Snapshot snap = snapshotService.push(
|
||||
project, req.ciphertext(), req.nonce(), req.message(), jwt.getSubject());
|
||||
return SnapshotMetaResponse.from(snap);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<SnapshotMetaResponse> list(
|
||||
@PathVariable UUID projectId,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
Project project = projectService.getForUser(projectId, jwt.getSubject());
|
||||
return snapshotService.list(project, limit)
|
||||
.stream()
|
||||
.map(SnapshotMetaResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Note: this must be declared before /{snapId} so Spring MVC matches "latest" as literal
|
||||
@GetMapping("/latest")
|
||||
public SnapshotFullResponse getLatest(
|
||||
@PathVariable UUID projectId,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
Project project = projectService.getForUser(projectId, jwt.getSubject());
|
||||
return SnapshotFullResponse.from(snapshotService.getLatest(project));
|
||||
}
|
||||
|
||||
@GetMapping("/{snapId}")
|
||||
public SnapshotFullResponse getById(
|
||||
@PathVariable UUID projectId,
|
||||
@PathVariable UUID snapId,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
Project project = projectService.getForUser(projectId, jwt.getSubject());
|
||||
return SnapshotFullResponse.from(snapshotService.getById(project, snapId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.envsync.backend.controller;
|
||||
|
||||
import com.envsync.backend.dto.RegisterPubkeyRequest;
|
||||
import com.envsync.backend.service.UserPubkeyService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/me")
|
||||
public class UserController {
|
||||
|
||||
private final UserPubkeyService userPubkeyService;
|
||||
|
||||
public UserController(UserPubkeyService userPubkeyService) {
|
||||
this.userPubkeyService = userPubkeyService;
|
||||
}
|
||||
|
||||
@PutMapping("/pubkey")
|
||||
public ResponseEntity<Void> registerPubkey(
|
||||
@Valid @RequestBody RegisterPubkeyRequest req,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
userPubkeyService.upsert(jwt.getSubject(), req.public_key());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.envsync.backend.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CreateProjectRequest(
|
||||
@NotBlank String name,
|
||||
@NotBlank String environment
|
||||
) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.envsync.backend.dto;
|
||||
|
||||
import com.envsync.backend.model.Project;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ProjectResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
String environment,
|
||||
Instant created_at
|
||||
) {
|
||||
public static ProjectResponse from(Project p) {
|
||||
return new ProjectResponse(p.getId(), p.getName(), p.getEnvironment(), p.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.envsync.backend.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record PushSnapshotRequest(
|
||||
@NotBlank String ciphertext,
|
||||
@NotBlank String nonce,
|
||||
String message
|
||||
) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.envsync.backend.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record RegisterPubkeyRequest(@NotBlank String public_key) {}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.envsync.backend.dto;
|
||||
|
||||
import com.envsync.backend.model.Snapshot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record SnapshotFullResponse(
|
||||
UUID id,
|
||||
int version,
|
||||
String ciphertext,
|
||||
String nonce,
|
||||
String message,
|
||||
String pushed_by,
|
||||
Instant created_at
|
||||
) {
|
||||
public static SnapshotFullResponse from(Snapshot s) {
|
||||
return new SnapshotFullResponse(
|
||||
s.getId(), s.getVersion(),
|
||||
s.getCiphertext(), s.getNonce(),
|
||||
s.getMessage(), s.getPushedBy(),
|
||||
s.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.envsync.backend.dto;
|
||||
|
||||
import com.envsync.backend.model.Snapshot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record SnapshotMetaResponse(
|
||||
UUID id,
|
||||
int version,
|
||||
String message,
|
||||
String pushed_by,
|
||||
Instant created_at
|
||||
) {
|
||||
public static SnapshotMetaResponse from(Snapshot s) {
|
||||
return new SnapshotMetaResponse(s.getId(), s.getVersion(), s.getMessage(), s.getPushedBy(), s.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.envsync.backend.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "projects")
|
||||
public class Project {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String environment;
|
||||
|
||||
/** Keycloak `sub` claim of the project owner. */
|
||||
@Column(name = "owner_id", nullable = false)
|
||||
private String ownerId;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
|
||||
// ── Getters / Setters ──────────────────────────────────────────────────
|
||||
|
||||
public UUID getId() { return id; }
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public String getEnvironment() { return environment; }
|
||||
public void setEnvironment(String environment) { this.environment = environment; }
|
||||
|
||||
public String getOwnerId() { return ownerId; }
|
||||
public void setOwnerId(String ownerId) { this.ownerId = ownerId; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.envsync.backend.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "snapshots")
|
||||
public class Snapshot {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "project_id", nullable = false)
|
||||
private Project project;
|
||||
|
||||
/** Monotonically increasing per-project version number (1, 2, 3, …). */
|
||||
@Column(nullable = false)
|
||||
private int version;
|
||||
|
||||
/** Base64-encoded XSalsa20-Poly1305 ciphertext of the vault. */
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String ciphertext;
|
||||
|
||||
/** Base64-encoded 24-byte nonce. */
|
||||
@Column(nullable = false, length = 64)
|
||||
private String nonce;
|
||||
|
||||
@Column
|
||||
private String message;
|
||||
|
||||
/** Keycloak `sub` of the user who pushed this snapshot. */
|
||||
@Column(name = "pushed_by")
|
||||
private String pushedBy;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
|
||||
// ── Getters / Setters ──────────────────────────────────────────────────
|
||||
|
||||
public UUID getId() { return id; }
|
||||
|
||||
public Project getProject() { return project; }
|
||||
public void setProject(Project project) { this.project = project; }
|
||||
|
||||
public int getVersion() { return version; }
|
||||
public void setVersion(int version) { this.version = version; }
|
||||
|
||||
public String getCiphertext() { return ciphertext; }
|
||||
public void setCiphertext(String ciphertext) { this.ciphertext = ciphertext; }
|
||||
|
||||
public String getNonce() { return nonce; }
|
||||
public void setNonce(String nonce) { this.nonce = nonce; }
|
||||
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
|
||||
public String getPushedBy() { return pushedBy; }
|
||||
public void setPushedBy(String pushedBy) { this.pushedBy = pushedBy; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.envsync.backend.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_pubkeys")
|
||||
public class UserPubkey {
|
||||
|
||||
/** Keycloak `sub` — primary key (one keypair per user). */
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private String userId;
|
||||
|
||||
/** Base64-encoded X25519 public key. */
|
||||
@Column(name = "public_key", nullable = false)
|
||||
private String publicKey;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
// ── Getters / Setters ──────────────────────────────────────────────────
|
||||
|
||||
public String getUserId() { return userId; }
|
||||
public void setUserId(String userId) { this.userId = userId; }
|
||||
|
||||
public String getPublicKey() { return publicKey; }
|
||||
public void setPublicKey(String publicKey) { this.publicKey = publicKey; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.envsync.backend.repository;
|
||||
|
||||
import com.envsync.backend.model.Project;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ProjectRepository extends JpaRepository<Project, UUID> {
|
||||
|
||||
List<Project> findByOwnerId(String ownerId);
|
||||
|
||||
Optional<Project> findByIdAndOwnerId(UUID id, String ownerId);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.envsync.backend.repository;
|
||||
|
||||
import com.envsync.backend.model.Project;
|
||||
import com.envsync.backend.model.Snapshot;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface SnapshotRepository extends JpaRepository<Snapshot, UUID> {
|
||||
|
||||
Optional<Snapshot> findTopByProjectOrderByVersionDesc(Project project);
|
||||
|
||||
List<Snapshot> findByProjectOrderByVersionDesc(Project project, Pageable pageable);
|
||||
|
||||
@Query("SELECT COALESCE(MAX(s.version), 0) FROM Snapshot s WHERE s.project = :project")
|
||||
int findMaxVersionByProject(@Param("project") Project project);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.envsync.backend.repository;
|
||||
|
||||
import com.envsync.backend.model.UserPubkey;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface UserPubkeyRepository extends JpaRepository<UserPubkey, String> {
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.envsync.backend.service;
|
||||
|
||||
import com.envsync.backend.model.Project;
|
||||
import com.envsync.backend.repository.ProjectRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class ProjectService {
|
||||
|
||||
private final ProjectRepository projectRepository;
|
||||
|
||||
public ProjectService(ProjectRepository projectRepository) {
|
||||
this.projectRepository = projectRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Project create(String name, String environment, String ownerId) {
|
||||
Project project = new Project();
|
||||
project.setName(name);
|
||||
project.setEnvironment(environment);
|
||||
project.setOwnerId(ownerId);
|
||||
return projectRepository.save(project);
|
||||
}
|
||||
|
||||
public List<Project> listForUser(String ownerId) {
|
||||
return projectRepository.findByOwnerId(ownerId);
|
||||
}
|
||||
|
||||
public Project getForUser(UUID id, String ownerId) {
|
||||
return projectRepository.findByIdAndOwnerId(id, ownerId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Project not found"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID id, String ownerId) {
|
||||
Project project = getForUser(id, ownerId);
|
||||
projectRepository.delete(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.envsync.backend.service;
|
||||
|
||||
import com.envsync.backend.model.Project;
|
||||
import com.envsync.backend.model.Snapshot;
|
||||
import com.envsync.backend.repository.SnapshotRepository;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class SnapshotService {
|
||||
|
||||
private final SnapshotRepository snapshotRepository;
|
||||
|
||||
public SnapshotService(SnapshotRepository snapshotRepository) {
|
||||
this.snapshotRepository = snapshotRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Snapshot push(Project project, String ciphertext, String nonce,
|
||||
String message, String pushedBy) {
|
||||
int nextVersion = snapshotRepository.findMaxVersionByProject(project) + 1;
|
||||
|
||||
Snapshot snap = new Snapshot();
|
||||
snap.setProject(project);
|
||||
snap.setVersion(nextVersion);
|
||||
snap.setCiphertext(ciphertext);
|
||||
snap.setNonce(nonce);
|
||||
snap.setMessage(message);
|
||||
snap.setPushedBy(pushedBy);
|
||||
return snapshotRepository.save(snap);
|
||||
}
|
||||
|
||||
public Snapshot getLatest(Project project) {
|
||||
return snapshotRepository.findTopByProjectOrderByVersionDesc(project)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No snapshots found"));
|
||||
}
|
||||
|
||||
public List<Snapshot> list(Project project, int limit) {
|
||||
return snapshotRepository.findByProjectOrderByVersionDesc(
|
||||
project, PageRequest.of(0, limit));
|
||||
}
|
||||
|
||||
public Snapshot getById(Project project, UUID snapId) {
|
||||
Snapshot snap = snapshotRepository.findById(snapId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Snapshot not found"));
|
||||
if (!snap.getProject().getId().equals(project.getId())) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Snapshot not found");
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.envsync.backend.service;
|
||||
|
||||
import com.envsync.backend.model.UserPubkey;
|
||||
import com.envsync.backend.repository.UserPubkeyRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class UserPubkeyService {
|
||||
|
||||
private final UserPubkeyRepository repo;
|
||||
|
||||
public UserPubkeyService(UserPubkeyRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void upsert(String userId, String publicKey) {
|
||||
UserPubkey entry = repo.findById(userId).orElseGet(UserPubkey::new);
|
||||
entry.setUserId(userId);
|
||||
entry.setPublicKey(publicKey);
|
||||
repo.save(entry);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
package com.envsync.backend;
|
||||
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* Integration test — requires the full Docker Compose stack (PostgreSQL, Keycloak).
|
||||
* Run with: docker compose up -d && ./mvnw test
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Disabled("Requires running Docker Compose stack (PostgreSQL + Keycloak). Run with integration profile.")
|
||||
class BackendApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.envsync.backend.service;
|
||||
|
||||
import com.envsync.backend.model.Project;
|
||||
import com.envsync.backend.repository.ProjectRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ProjectServiceTest {
|
||||
|
||||
@Mock
|
||||
private ProjectRepository projectRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ProjectService projectService;
|
||||
|
||||
private Project sampleProject;
|
||||
private final String ownerId = "user-123";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sampleProject = new Project();
|
||||
sampleProject.setName("test-app");
|
||||
sampleProject.setEnvironment("development");
|
||||
sampleProject.setOwnerId(ownerId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_savesAndReturnsProject() {
|
||||
when(projectRepository.save(any(Project.class))).thenReturn(sampleProject);
|
||||
|
||||
Project result = projectService.create("test-app", "development", ownerId);
|
||||
|
||||
assertThat(result.getName()).isEqualTo("test-app");
|
||||
assertThat(result.getEnvironment()).isEqualTo("development");
|
||||
assertThat(result.getOwnerId()).isEqualTo(ownerId);
|
||||
verify(projectRepository, times(1)).save(any(Project.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listForUser_returnsUserProjects() {
|
||||
when(projectRepository.findByOwnerId(ownerId)).thenReturn(List.of(sampleProject));
|
||||
|
||||
List<Project> result = projectService.listForUser(ownerId);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getName()).isEqualTo("test-app");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getForUser_whenFound_returnsProject() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.of(sampleProject));
|
||||
|
||||
Project result = projectService.getForUser(id, ownerId);
|
||||
|
||||
assertThat(result).isEqualTo(sampleProject);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getForUser_whenNotFound_throws404() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> projectService.getForUser(id, ownerId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Project not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_whenFound_deletesProject() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.of(sampleProject));
|
||||
|
||||
projectService.delete(id, ownerId);
|
||||
|
||||
verify(projectRepository, times(1)).delete(sampleProject);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_whenNotFound_throws404() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> projectService.delete(id, ownerId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Project not found");
|
||||
verify(projectRepository, never()).delete(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.envsync.backend.service;
|
||||
|
||||
import com.envsync.backend.model.Project;
|
||||
import com.envsync.backend.model.Snapshot;
|
||||
import com.envsync.backend.repository.SnapshotRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SnapshotServiceTest {
|
||||
|
||||
@Mock
|
||||
private SnapshotRepository snapshotRepository;
|
||||
|
||||
@InjectMocks
|
||||
private SnapshotService snapshotService;
|
||||
|
||||
private Project project;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
project = new Project();
|
||||
project.setName("test-app");
|
||||
project.setEnvironment("development");
|
||||
project.setOwnerId("user-123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void push_createsSnapshotWithNextVersion() {
|
||||
when(snapshotRepository.findMaxVersionByProject(project)).thenReturn(5);
|
||||
Snapshot saved = new Snapshot();
|
||||
saved.setVersion(6);
|
||||
when(snapshotRepository.save(any(Snapshot.class))).thenReturn(saved);
|
||||
|
||||
Snapshot result = snapshotService.push(project, "ct", "nonce", "init", "user-123");
|
||||
|
||||
assertThat(result.getVersion()).isEqualTo(6);
|
||||
verify(snapshotRepository).save(argThat(s ->
|
||||
s.getVersion() == 6 &&
|
||||
s.getCiphertext().equals("ct") &&
|
||||
s.getNonce().equals("nonce") &&
|
||||
s.getMessage().equals("init") &&
|
||||
s.getPushedBy().equals("user-123")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void push_firstSnapshot_usesVersion1() {
|
||||
when(snapshotRepository.findMaxVersionByProject(project)).thenReturn(0);
|
||||
Snapshot saved = new Snapshot();
|
||||
saved.setVersion(1);
|
||||
when(snapshotRepository.save(any(Snapshot.class))).thenReturn(saved);
|
||||
|
||||
Snapshot result = snapshotService.push(project, "ct", "nonce", null, "user-123");
|
||||
|
||||
assertThat(result.getVersion()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLatest_whenExists_returnsSnapshot() {
|
||||
Snapshot snap = new Snapshot();
|
||||
snap.setVersion(3);
|
||||
when(snapshotRepository.findTopByProjectOrderByVersionDesc(project))
|
||||
.thenReturn(Optional.of(snap));
|
||||
|
||||
Snapshot result = snapshotService.getLatest(project);
|
||||
|
||||
assertThat(result.getVersion()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLatest_whenNone_throws404() {
|
||||
when(snapshotRepository.findTopByProjectOrderByVersionDesc(project))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> snapshotService.getLatest(project))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("No snapshots found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_whenBelongsToProject_returnsSnapshot() {
|
||||
UUID snapId = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
|
||||
// Need to give project an ID for comparison
|
||||
Project proj = mock(Project.class);
|
||||
when(proj.getId()).thenReturn(projectId);
|
||||
|
||||
Snapshot snap = new Snapshot();
|
||||
snap.setProject(proj);
|
||||
when(snapshotRepository.findById(snapId)).thenReturn(Optional.of(snap));
|
||||
|
||||
Project requestedProject = mock(Project.class);
|
||||
when(requestedProject.getId()).thenReturn(projectId);
|
||||
|
||||
Snapshot result = snapshotService.getById(requestedProject, snapId);
|
||||
assertThat(result).isEqualTo(snap);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_whenBelongsToDifferentProject_throws404() {
|
||||
UUID snapId = UUID.randomUUID();
|
||||
|
||||
Project proj = mock(Project.class);
|
||||
when(proj.getId()).thenReturn(UUID.randomUUID());
|
||||
|
||||
Snapshot snap = new Snapshot();
|
||||
snap.setProject(proj);
|
||||
when(snapshotRepository.findById(snapId)).thenReturn(Optional.of(snap));
|
||||
|
||||
Project otherProject = mock(Project.class);
|
||||
when(otherProject.getId()).thenReturn(UUID.randomUUID());
|
||||
|
||||
assertThatThrownBy(() -> snapshotService.getById(otherProject, snapId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Snapshot not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_returnsPagedResults() {
|
||||
Snapshot s1 = new Snapshot();
|
||||
s1.setVersion(2);
|
||||
Snapshot s2 = new Snapshot();
|
||||
s2.setVersion(1);
|
||||
when(snapshotRepository.findByProjectOrderByVersionDesc(eq(project), any(Pageable.class)))
|
||||
.thenReturn(List.of(s1, s2));
|
||||
|
||||
List<Snapshot> result = snapshotService.list(project, 10);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result.get(0).getVersion()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Generated
+122
-2
@@ -67,6 +67,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -107,12 +119,36 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -184,6 +220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -198,6 +235,18 @@ dependencies = [
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.0.0"
|
||||
@@ -279,6 +328,15 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
@@ -302,6 +360,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
@@ -325,6 +393,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "6.0.0"
|
||||
@@ -419,6 +498,8 @@ name = "envsync-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"comfy-table",
|
||||
@@ -534,6 +615,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1137,6 +1228,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1282,7 +1384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1292,9 +1394,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
@@ -2016,6 +2124,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -2082,6 +2196,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -5,7 +5,9 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
clap = "4.5.60"
|
||||
clap = { version = "4.5.60", features = ["derive", "env"] }
|
||||
argon2 = "0.5"
|
||||
base64 = "0.22"
|
||||
comfy-table = "7.2.2"
|
||||
console = "0.16.2"
|
||||
ctrlc = "3.5.2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
@@ -7,7 +7,6 @@ use tokio::time::sleep;
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeviceAuthRequest {
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -20,59 +19,88 @@ struct DeviceAuthResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: u64,
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
pub async fn authenticate(client: &Client, keycloak_url: &str, client_id: &str) -> Result<String> {
|
||||
// 1. Request device code
|
||||
let device_auth_url = format!("{}/protocol/openid-connect/auth/device", keycloak_url);
|
||||
|
||||
/// Exchange a refresh token for a new access token.
|
||||
pub async fn refresh_token(
|
||||
client: &Client,
|
||||
keycloak_url: &str,
|
||||
client_id: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<TokenResponse> {
|
||||
let token_url = format!("{keycloak_url}/protocol/openid-connect/token");
|
||||
let params = [
|
||||
("grant_type", "refresh_token"),
|
||||
("client_id", client_id),
|
||||
("refresh_token", refresh_token),
|
||||
];
|
||||
let resp = client.post(&token_url).form(¶ms).send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
|
||||
if status.is_success() {
|
||||
Ok(serde_json::from_str(&text)
|
||||
.map_err(|e| anyhow!("Failed to parse refresh response: {e}"))?)
|
||||
} else {
|
||||
Err(anyhow!("Token refresh failed ({status}): {text}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authenticate(
|
||||
client: &Client,
|
||||
keycloak_url: &str,
|
||||
client_id: &str,
|
||||
) -> Result<TokenResponse> {
|
||||
let device_auth_url = format!("{keycloak_url}/protocol/openid-connect/auth/device");
|
||||
|
||||
let params = [("client_id", client_id)];
|
||||
let response = client.post(&device_auth_url).form(¶ms).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Device auth failed ({}): {}", status, body);
|
||||
anyhow::bail!("Device auth failed ({status}): {body}");
|
||||
}
|
||||
|
||||
let resp: DeviceAuthResponse = response.json().await?;
|
||||
|
||||
println!("Open this URL in your browser: {}", resp.verification_uri);
|
||||
println!("Open this URL in your browser:");
|
||||
println!(" {}", resp.verification_uri);
|
||||
println!("Enter the code: {}", resp.user_code);
|
||||
|
||||
// 2. Poll for token
|
||||
let token_url = format!("{}/protocol/openid-connect/token", keycloak_url);
|
||||
let interval = Duration::from_secs(resp.interval);
|
||||
let token_url = format!("{keycloak_url}/protocol/openid-connect/token");
|
||||
let interval = Duration::from_secs(resp.interval.max(5));
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed().as_secs() < resp.expires_in {
|
||||
loop {
|
||||
if start.elapsed().as_secs() >= resp.expires_in {
|
||||
anyhow::bail!("Authentication timed out. Please try again.");
|
||||
}
|
||||
|
||||
sleep(interval).await;
|
||||
|
||||
let params = [
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
("device_code", &resp.device_code),
|
||||
("client_id", client_id),
|
||||
];
|
||||
let token_resp = client.post(&token_url).form(¶ms).send().await?;
|
||||
|
||||
let status = token_resp.status();
|
||||
let text = token_resp.text().await.unwrap_or_default();
|
||||
println!("Token response status: {}, body: {}", status, text);
|
||||
|
||||
if status.is_success() {
|
||||
let token: TokenResponse = serde_json::from_str(&text)?;
|
||||
return Ok(token.access_token);
|
||||
} else if status.as_u16() == 400 && text.contains("authorization_pending") {
|
||||
sleep(interval).await;
|
||||
return Ok(token);
|
||||
} else if status.as_u16() == 400
|
||||
&& (text.contains("authorization_pending") || text.contains("slow_down"))
|
||||
{
|
||||
continue;
|
||||
} else {
|
||||
anyhow::bail!("Unexpected token response: {} - {}", status, text);
|
||||
anyhow::bail!("Token request failed ({status}): {text}");
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("Authentication timed out");
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod vault;
|
||||
@@ -0,0 +1,190 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::EnvSyncError;
|
||||
|
||||
// ── Request types ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateProjectRequest {
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PushSnapshotRequest {
|
||||
pub ciphertext: String,
|
||||
pub nonce: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RegisterPubkeyRequest {
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
// ── Response types ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ProjectResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SnapshotMetaResponse {
|
||||
pub id: String,
|
||||
pub version: u32,
|
||||
pub message: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SnapshotFullResponse {
|
||||
pub id: String,
|
||||
pub version: u32,
|
||||
pub ciphertext: String,
|
||||
pub nonce: String,
|
||||
pub message: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// ── Client ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct VaultClient {
|
||||
client: Client,
|
||||
api_url: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl VaultClient {
|
||||
pub fn new(api_url: impl Into<String>, token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_url: api_url.into(),
|
||||
token: token.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn check(&self, resp: reqwest::Response) -> Result<reqwest::Response, EnvSyncError> {
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let message = resp.text().await.unwrap_or_default();
|
||||
return Err(EnvSyncError::ApiError { status, message });
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn create_project(
|
||||
&self,
|
||||
name: &str,
|
||||
environment: &str,
|
||||
) -> Result<ProjectResponse, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/projects", self.api_url))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&CreateProjectRequest {
|
||||
name: name.to_string(),
|
||||
environment: environment.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<ProjectResponse>, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/projects", self.api_url))
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn push_snapshot(
|
||||
&self,
|
||||
project_id: &str,
|
||||
req: PushSnapshotRequest,
|
||||
) -> Result<SnapshotMetaResponse, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/projects/{project_id}/snapshots", self.api_url))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn get_latest_snapshot(
|
||||
&self,
|
||||
project_id: &str,
|
||||
) -> Result<Option<SnapshotFullResponse>, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/api/projects/{project_id}/snapshots/latest",
|
||||
self.api_url
|
||||
))
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().as_u16() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(self.check(resp).await?.json().await?))
|
||||
}
|
||||
|
||||
pub async fn list_snapshots(
|
||||
&self,
|
||||
project_id: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<SnapshotMetaResponse>, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/api/projects/{project_id}/snapshots?limit={limit}",
|
||||
self.api_url
|
||||
))
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn get_snapshot(
|
||||
&self,
|
||||
project_id: &str,
|
||||
snap_id: &str,
|
||||
) -> Result<SnapshotFullResponse, EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/api/projects/{project_id}/snapshots/{snap_id}",
|
||||
self.api_url
|
||||
))
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(self.check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn register_pubkey(&self, public_key: &str) -> Result<(), EnvSyncError> {
|
||||
let resp = self
|
||||
.client
|
||||
.put(format!("{}/api/users/me/pubkey", self.api_url))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&RegisterPubkeyRequest {
|
||||
public_key: public_key.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
self.check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "envsync",
|
||||
about = "Zero-knowledge encrypted environment variable manager",
|
||||
version
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// API server URL (overrides config, env: ENVSYNC_API_URL)
|
||||
#[arg(long, env = "ENVSYNC_API_URL", global = true)]
|
||||
pub api_url: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Authenticate with EnvSync via device code flow
|
||||
Login,
|
||||
|
||||
/// Log out and clear stored credentials
|
||||
Logout,
|
||||
|
||||
/// Initialize a new project in the current directory
|
||||
Init {
|
||||
/// Project name (defaults to directory name)
|
||||
#[arg(long, short)]
|
||||
name: Option<String>,
|
||||
/// Environment (e.g. development, staging, production)
|
||||
#[arg(long, short, default_value = "development")]
|
||||
env: Option<String>,
|
||||
},
|
||||
|
||||
/// Encrypt and push the local .env to the server
|
||||
Push {
|
||||
/// Commit message describing this snapshot
|
||||
#[arg(long, short)]
|
||||
message: Option<String>,
|
||||
/// Force push even when behind remote version
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// Decrypt and pull the latest .env snapshot from the server
|
||||
Pull {
|
||||
/// Overwrite local .env without prompting
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// Show diff between local .env and latest remote snapshot
|
||||
Diff {
|
||||
/// Print actual values instead of masking with ***
|
||||
#[arg(long)]
|
||||
show_values: bool,
|
||||
},
|
||||
|
||||
/// Show snapshot history for this project
|
||||
Log {
|
||||
/// Maximum number of entries to display
|
||||
#[arg(long, short, default_value = "20")]
|
||||
limit: u32,
|
||||
},
|
||||
|
||||
/// Set a key in the vault and push a new snapshot
|
||||
Set {
|
||||
key: String,
|
||||
value: String,
|
||||
/// Commit message
|
||||
#[arg(long, short)]
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
/// Remove a key from the vault and push a new snapshot
|
||||
Unset {
|
||||
key: String,
|
||||
/// Commit message
|
||||
#[arg(long, short)]
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
/// Roll back the vault to a specific snapshot version
|
||||
Rollback {
|
||||
/// Version number to restore
|
||||
version: u32,
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long, short)]
|
||||
yes: bool,
|
||||
},
|
||||
|
||||
/// Show current auth status and project configuration
|
||||
Status,
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use console::style;
|
||||
use dialoguer::Password;
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(show_values: bool, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let project = LocalProject::load()?;
|
||||
|
||||
// Load local .env
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
let local_map: BTreeMap<String, String> = if env_path.exists() {
|
||||
let mut m = BTreeMap::new();
|
||||
for item in dotenvy::from_path_iter(&env_path)? {
|
||||
let (k, v) = item?;
|
||||
m.insert(k, v);
|
||||
}
|
||||
m
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
|
||||
// Fetch remote
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
let remote_map = match vc.get_latest_snapshot(&project.project_id).await? {
|
||||
None => {
|
||||
println!("No remote snapshot found. All local keys are new:");
|
||||
for k in local_map.keys() {
|
||||
let display = if show_values {
|
||||
local_map[k].as_str().to_string()
|
||||
} else {
|
||||
"***".to_string()
|
||||
};
|
||||
println!(" {}", style(format!("+{k}={display}")).green());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Some(snap) => {
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
let ciphertext = B64.decode(&snap.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&snap.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
|
||||
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
|
||||
let m: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
|
||||
m
|
||||
}
|
||||
};
|
||||
|
||||
let all_keys: std::collections::BTreeSet<&String> =
|
||||
local_map.keys().chain(remote_map.keys()).collect();
|
||||
|
||||
let mut has_diff = false;
|
||||
for key in all_keys {
|
||||
match (local_map.get(key), remote_map.get(key)) {
|
||||
(Some(lv), Some(rv)) if lv == rv => {} // unchanged
|
||||
(Some(lv), Some(rv)) => {
|
||||
has_diff = true;
|
||||
if show_values {
|
||||
println!(" {}", style(format!("~{key}: {rv} → {lv}")).yellow());
|
||||
} else {
|
||||
println!(" {}", style(format!("~{key}")).yellow());
|
||||
}
|
||||
}
|
||||
(Some(lv), None) => {
|
||||
has_diff = true;
|
||||
let display = if show_values { lv.as_str() } else { "***" };
|
||||
println!(" {}", style(format!("+{key}={display}")).green());
|
||||
}
|
||||
(None, Some(rv)) => {
|
||||
has_diff = true;
|
||||
let display = if show_values { rv.as_str() } else { "***" };
|
||||
println!(" {}", style(format!("-{key}={display}")).red());
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_diff {
|
||||
println!("No differences — local matches remote.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,118 +1,89 @@
|
||||
use anyhow::{Result, Context, anyhow};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::Result;
|
||||
use dialoguer::{Confirm, Input};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sodiumoxide::crypto::box__;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AuthConfig {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||
pub user_id: String,
|
||||
pub user_email: String,
|
||||
}
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{Config, LocalProject};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct KeypairConfig {
|
||||
pub public_key: String, // base64 encoded
|
||||
pub private_key: String, // base64 encrypted with a passphrase?
|
||||
}
|
||||
pub async fn run(
|
||||
name: Option<String>,
|
||||
env: Option<String>,
|
||||
api_url: &str,
|
||||
) -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct VaultContext {
|
||||
pub project_id: String,
|
||||
pub project_name: String,
|
||||
pub environment: String,
|
||||
pub vault_key: Option<String>, // base64 encrypted with user's public key
|
||||
}
|
||||
// Determine defaults from current directory
|
||||
let cwd = std::env::current_dir()?;
|
||||
let default_name = cwd
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("my-project")
|
||||
.to_string();
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct Config {
|
||||
pub api_url: String,
|
||||
pub auth: Option<AuthConfig>,
|
||||
pub keypair: Option<KeypairConfig>,
|
||||
pub current_context: Option<VaultContext>,
|
||||
}
|
||||
let project_name: String = if let Some(n) = name {
|
||||
n
|
||||
} else {
|
||||
Input::new()
|
||||
.with_prompt("Project name")
|
||||
.default(default_name)
|
||||
.interact_text()?
|
||||
};
|
||||
|
||||
impl Config {
|
||||
pub fn path() -> PathBuf {
|
||||
ProjectDirs::from("com", "envsync", "cli")
|
||||
.expect("Failed to get config directory")
|
||||
.config_dir()
|
||||
.join("config.toml")
|
||||
}
|
||||
let environment: String = if let Some(e) = env {
|
||||
e
|
||||
} else {
|
||||
Input::new()
|
||||
.with_prompt("Environment")
|
||||
.default("development".to_string())
|
||||
.interact_text()?
|
||||
};
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::path();
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read config from {:?}", path))?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
println!("Creating project '{project_name}' ({environment})...");
|
||||
|
||||
// Check .envsync doesn't already exist
|
||||
if LocalProject::path().exists() {
|
||||
let overwrite = Confirm::new()
|
||||
.with_prompt("A .envsync file already exists. Re-initialize?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !overwrite {
|
||||
println!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
let project = vc.create_project(&project_name, &environment).await?;
|
||||
|
||||
let local = LocalProject {
|
||||
project_id: project.id.clone(),
|
||||
project_name: project.name.clone(),
|
||||
environment: project.environment.clone(),
|
||||
latest_version: 0,
|
||||
};
|
||||
local.save()?;
|
||||
|
||||
// Ensure .envsync is in .gitignore
|
||||
add_to_gitignore(&cwd)?;
|
||||
|
||||
println!("Initialized project '{}' (id: {})", project.name, project.id);
|
||||
println!(" Environment : {}", project.environment);
|
||||
println!(" .envsync : written (gitignored)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_to_gitignore(dir: &std::path::Path) -> Result<()> {
|
||||
let path = dir.join(".gitignore");
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
if !content.lines().any(|l| l.trim() == ".envsync") {
|
||||
let sep = if content.ends_with('\n') || content.is_empty() { "" } else { "\n" };
|
||||
fs::write(&path, format!("{content}{sep}.envsync\n"))?;
|
||||
}
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_auth_token(&self) -> Result<String> {
|
||||
self.auth
|
||||
.as_ref()
|
||||
.map(|a| a.access_token.clone())
|
||||
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync init' first."))
|
||||
}
|
||||
|
||||
pub fn get_current_context(&self) -> Result<&VaultContext> {
|
||||
self.current_context
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("No project initialized. Run 'envsync init' in your project."))
|
||||
}
|
||||
|
||||
pub fn set_current_context(&mut self, context: VaultContext) {
|
||||
self.current_context = Some(context);
|
||||
} else {
|
||||
fs::write(&path, ".envsync\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectConfig {
|
||||
pub project_name: String,
|
||||
pub environment: String,
|
||||
pub env_file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
pub fn from_current_dir(project_name: Option<String>, environment: Option<String>) -> Result<Self> {
|
||||
let current_dir = std::env::current_dir()?;
|
||||
|
||||
// Look for existing .env file
|
||||
let env_file_path = current_dir.join(".env");
|
||||
|
||||
// If project name not provided, use directory name
|
||||
let project_name = match project_name {
|
||||
Some(name) => name,
|
||||
None => current_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| anyhow!("Could not determine project name from directory"))?
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let environment = environment.unwrap_or_else(|| "development".to_string());
|
||||
|
||||
Ok(ProjectConfig {
|
||||
project_name,
|
||||
environment,
|
||||
env_file_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use anyhow::Result;
|
||||
use comfy_table::{Cell, Color, Table};
|
||||
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{Config, LocalProject};
|
||||
|
||||
pub async fn run(limit: u32, api_url: &str) -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let project = LocalProject::load()?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
let snapshots = vc.list_snapshots(&project.project_id, limit).await?;
|
||||
|
||||
if snapshots.is_empty() {
|
||||
println!("No snapshots found for '{}'.", project.project_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut table = Table::new();
|
||||
table.set_header(vec!["", "Version", "Date", "Message"]);
|
||||
|
||||
for snap in &snapshots {
|
||||
let current_marker = if snap.version == project.latest_version {
|
||||
"*"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let msg = snap.message.as_deref().unwrap_or("-");
|
||||
let version_cell = if snap.version == project.latest_version {
|
||||
Cell::new(snap.version).fg(Color::Green)
|
||||
} else {
|
||||
Cell::new(snap.version)
|
||||
};
|
||||
table.add_row(vec![
|
||||
Cell::new(current_marker),
|
||||
version_cell,
|
||||
Cell::new(&snap.created_at),
|
||||
Cell::new(msg),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("Project: {} ({})", project.project_name, project.environment);
|
||||
println!("{table}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use dialoguer::Password;
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::api::auth;
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{AuthConfig, Config, KeypairConfig};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let mut config = Config::load()?;
|
||||
let keycloak_url = config.keycloak_url.clone();
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
|
||||
println!("Authenticating with EnvSync...");
|
||||
let client = Client::new();
|
||||
let token_resp = auth::authenticate(&client, &keycloak_url, "envsync-app-cli").await?;
|
||||
|
||||
// Decode JWT payload (middle segment, URL-safe base64 without padding)
|
||||
let parts: Vec<&str> = token_resp.access_token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
anyhow::bail!("Invalid JWT format received from Keycloak");
|
||||
}
|
||||
let payload_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|e| anyhow!("Failed to decode JWT payload: {e}"))?;
|
||||
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes)?;
|
||||
|
||||
let user_id = claims["sub"].as_str().unwrap_or("").to_string();
|
||||
let user_email = claims["email"].as_str().unwrap_or("").to_string();
|
||||
let exp = claims["exp"].as_i64().unwrap_or(0);
|
||||
|
||||
// Generate keypair on first login
|
||||
let keypair_config = if config.keypair.is_none() {
|
||||
println!("Generating a new encryption keypair...");
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Enter a passphrase to protect your private key")
|
||||
.with_confirmation("Confirm passphrase", "Passphrases do not match")
|
||||
.interact()?;
|
||||
|
||||
let salt = crypto::generate_salt();
|
||||
let master_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
let (pub_key, sec_key) = crypto::generate_keypair();
|
||||
let (ct, nonce) = crypto::encrypt_privkey(&sec_key, &master_key)?;
|
||||
|
||||
KeypairConfig {
|
||||
public_key: B64.encode(pub_key.0),
|
||||
private_key_ct: B64.encode(&ct),
|
||||
privkey_nonce: B64.encode(nonce.0),
|
||||
argon2_salt: B64.encode(salt),
|
||||
}
|
||||
} else {
|
||||
println!("Using existing keypair.");
|
||||
config.keypair.clone().unwrap()
|
||||
};
|
||||
|
||||
// Register public key with the backend
|
||||
let vc = VaultClient::new(effective_api_url, &token_resp.access_token);
|
||||
if let Err(e) = vc.register_pubkey(&keypair_config.public_key).await {
|
||||
eprintln!("Warning: failed to register public key with server: {e}");
|
||||
}
|
||||
|
||||
config.auth = Some(AuthConfig {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token,
|
||||
expires_at: exp,
|
||||
user_id: user_id.clone(),
|
||||
user_email: user_email.clone(),
|
||||
});
|
||||
config.keypair = Some(keypair_config);
|
||||
config.save()?;
|
||||
|
||||
println!("Logged in as {user_email} (sub: {user_id})");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let mut config = Config::load()?;
|
||||
|
||||
if config.auth.is_none() {
|
||||
println!("Not currently logged in.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let email = config
|
||||
.auth
|
||||
.as_ref()
|
||||
.map(|a| a.user_email.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
config.auth = None;
|
||||
// Keep the keypair — user can re-authenticate without re-generating keys.
|
||||
config.save()?;
|
||||
|
||||
println!("Logged out ({email}).");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod diff;
|
||||
pub mod init;
|
||||
pub mod log;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod pull;
|
||||
pub mod push;
|
||||
pub mod rollback;
|
||||
pub mod set;
|
||||
pub mod status;
|
||||
pub mod unset;
|
||||
@@ -0,0 +1,88 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::{Confirm, Password};
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::VaultClient;
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(force: bool, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
let latest = match vc.get_latest_snapshot(&project.project_id).await? {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
println!("No snapshots found for this project yet.");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if !force && latest.version <= project.latest_version {
|
||||
println!("Already up to date (v{}).", project.latest_version);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
if !force && env_path.exists() {
|
||||
let proceed = Confirm::new()
|
||||
.with_prompt("This will overwrite your local .env. Continue?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !proceed {
|
||||
println!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Derive vault key
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
// Decrypt
|
||||
let ciphertext = B64.decode(&latest.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&latest.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
|
||||
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
|
||||
let env_map: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
|
||||
|
||||
write_env_file(&env_path, &env_map)?;
|
||||
|
||||
project.latest_version = latest.version;
|
||||
project.save()?;
|
||||
|
||||
println!("Pulled v{} → .env ({} keys)", latest.version, env_map.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_env_file(
|
||||
path: &std::path::Path,
|
||||
map: &BTreeMap<String, String>,
|
||||
) -> Result<()> {
|
||||
let content: String = map
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}\n"))
|
||||
.collect();
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::Password;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(message: Option<String>, force: bool, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
if !env_path.exists() {
|
||||
anyhow::bail!("No .env file found in the current directory.");
|
||||
}
|
||||
|
||||
// Prompt passphrase and derive vault key
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
// Parse .env → BTreeMap (deterministic ordering)
|
||||
let env_map = parse_env_file(&env_path)?;
|
||||
let plaintext = serde_json::to_vec(&env_map)?;
|
||||
|
||||
// Encrypt
|
||||
let (ciphertext, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
// Conflict check
|
||||
if !force {
|
||||
if let Some(latest) = vc.get_latest_snapshot(&project.project_id).await? {
|
||||
if latest.version > project.latest_version {
|
||||
anyhow::bail!(
|
||||
"Remote is at v{}, you have v{}. Run 'envsync pull' first or use --force.",
|
||||
latest.version,
|
||||
project.latest_version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let snap = vc
|
||||
.push_snapshot(
|
||||
&project.project_id,
|
||||
PushSnapshotRequest {
|
||||
ciphertext: B64.encode(&ciphertext),
|
||||
nonce: B64.encode(nonce.0),
|
||||
message,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
project.latest_version = snap.version;
|
||||
project.save()?;
|
||||
|
||||
println!(
|
||||
"Pushed v{} to '{}' ({})",
|
||||
snap.version, project.project_name, project.environment
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_env_file(path: &Path) -> Result<BTreeMap<String, String>> {
|
||||
let mut map = BTreeMap::new();
|
||||
for item in dotenvy::from_path_iter(path)? {
|
||||
let (k, v) = item?;
|
||||
map.insert(k, v);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::{Confirm, Password};
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(version: u32, yes: bool, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
// Find the snapshot with the target version
|
||||
let snapshots = vc.list_snapshots(&project.project_id, 1000).await?;
|
||||
let target_meta = snapshots
|
||||
.iter()
|
||||
.find(|s| s.version == version)
|
||||
.ok_or_else(|| anyhow!("Snapshot v{version} not found."))?;
|
||||
|
||||
println!(
|
||||
"Rolling back to v{} — {} — {}",
|
||||
target_meta.version,
|
||||
target_meta.created_at,
|
||||
target_meta.message.as_deref().unwrap_or("-")
|
||||
);
|
||||
|
||||
if !yes {
|
||||
let confirmed = Confirm::new()
|
||||
.with_prompt("This will push a new snapshot with the content from that version. Continue?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !confirmed {
|
||||
println!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch full snapshot
|
||||
let snap = vc.get_snapshot(&project.project_id, &target_meta.id).await?;
|
||||
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
// Decrypt
|
||||
let ciphertext = B64.decode(&snap.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&snap.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
|
||||
let env_map: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
|
||||
|
||||
// Re-encrypt with a fresh nonce and push as new snapshot (preserves history)
|
||||
let (new_ct, new_nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||
let new_snap = vc
|
||||
.push_snapshot(
|
||||
&project.project_id,
|
||||
PushSnapshotRequest {
|
||||
ciphertext: B64.encode(&new_ct),
|
||||
nonce: B64.encode(new_nonce.0),
|
||||
message: Some(format!("Rollback to v{version}")),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Write .env
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
crate::commands::pull::write_env_file(&env_path, &env_map)?;
|
||||
|
||||
project.latest_version = new_snap.version;
|
||||
project.save()?;
|
||||
|
||||
println!(
|
||||
"Rolled back to v{version} content → pushed as v{} ({} keys)",
|
||||
new_snap.version,
|
||||
env_map.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::Password;
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(key: String, value: String, message: Option<String>, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
// Pull current remote state (or start fresh)
|
||||
let mut env_map: BTreeMap<String, String> =
|
||||
match vc.get_latest_snapshot(&project.project_id).await? {
|
||||
Some(snap) => {
|
||||
let ct = B64.decode(&snap.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&snap.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
let plaintext = crypto::decrypt_vault(&ct, &nonce, &vault_key)?;
|
||||
serde_json::from_slice(&plaintext)?
|
||||
}
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
|
||||
env_map.insert(key.clone(), value.clone());
|
||||
|
||||
let plaintext = serde_json::to_vec(&env_map)?;
|
||||
let (ct, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||
|
||||
let msg = message.unwrap_or_else(|| format!("Set {key}"));
|
||||
let snap = vc
|
||||
.push_snapshot(
|
||||
&project.project_id,
|
||||
PushSnapshotRequest {
|
||||
ciphertext: B64.encode(&ct),
|
||||
nonce: B64.encode(nonce.0),
|
||||
message: Some(msg),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update local .env
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
crate::commands::pull::write_env_file(&env_path, &env_map)?;
|
||||
|
||||
project.latest_version = snap.version;
|
||||
project.save()?;
|
||||
|
||||
println!("Set {key}={value} → pushed v{}", snap.version);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::config::{Config, LocalProject};
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
|
||||
println!("EnvSync Status");
|
||||
println!("{}", "─".repeat(40));
|
||||
|
||||
// Auth status
|
||||
match &config.auth {
|
||||
None => {
|
||||
println!("Auth: not logged in");
|
||||
println!(" Run 'envsync login' to authenticate.");
|
||||
}
|
||||
Some(auth) => {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
let expires_in = auth.expires_at - now;
|
||||
let expiry_str = if expires_in > 0 {
|
||||
let dt = DateTime::<Utc>::from_timestamp(auth.expires_at, 0)
|
||||
.map(|d| d.format("%H:%M:%S UTC").to_string())
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
format!("expires at {dt} ({expires_in}s)")
|
||||
} else {
|
||||
"EXPIRED — run 'envsync login'".to_string()
|
||||
};
|
||||
|
||||
println!("Auth: logged in");
|
||||
println!("User: {}", auth.user_email);
|
||||
println!("Token: {expiry_str}");
|
||||
println!("Refresh: {}", if auth.refresh_token.is_some() { "available" } else { "none" });
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "─".repeat(40));
|
||||
|
||||
// Keypair status
|
||||
match &config.keypair {
|
||||
None => println!("Keypair: not generated"),
|
||||
Some(kp) => println!("Keypair: {} (X25519)", &kp.public_key[..16]),
|
||||
}
|
||||
|
||||
println!("{}", "─".repeat(40));
|
||||
|
||||
// Local project status
|
||||
match LocalProject::load() {
|
||||
Err(_) => println!("Project: no .envsync file (run 'envsync init')"),
|
||||
Ok(proj) => {
|
||||
println!("Project: {}", proj.project_name);
|
||||
println!("Env: {}", proj.environment);
|
||||
println!("Local v: v{}", proj.latest_version);
|
||||
println!("ID: {}", proj.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "─".repeat(40));
|
||||
println!("API: {}", config.api_url);
|
||||
println!("Keycloak: {}", config.keycloak_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use dialoguer::Password;
|
||||
use sodiumoxide::crypto::secretbox;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||
use crate::config::{Config, LocalProject};
|
||||
use crate::crypto;
|
||||
|
||||
pub async fn run(key: String, message: Option<String>, api_url: &str) -> Result<()> {
|
||||
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let auth = config.require_auth()?;
|
||||
let keypair = config.require_keypair()?;
|
||||
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||
let mut project = LocalProject::load()?;
|
||||
|
||||
let passphrase = Password::new()
|
||||
.with_prompt("Passphrase")
|
||||
.interact()?;
|
||||
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||
let salt: [u8; 32] = salt_bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||
|
||||
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||
|
||||
let mut env_map: BTreeMap<String, String> =
|
||||
match vc.get_latest_snapshot(&project.project_id).await? {
|
||||
Some(snap) => {
|
||||
let ct = B64.decode(&snap.ciphertext)
|
||||
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||
let nonce_bytes = B64.decode(&snap.nonce)
|
||||
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||
let plaintext = crypto::decrypt_vault(&ct, &nonce, &vault_key)?;
|
||||
serde_json::from_slice(&plaintext)?
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("No remote snapshot found; nothing to unset.");
|
||||
}
|
||||
};
|
||||
|
||||
if !env_map.contains_key(&key) {
|
||||
anyhow::bail!("Key '{key}' not found in the vault.");
|
||||
}
|
||||
env_map.remove(&key);
|
||||
|
||||
let plaintext = serde_json::to_vec(&env_map)?;
|
||||
let (ct, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||
|
||||
let msg = message.unwrap_or_else(|| format!("Unset {key}"));
|
||||
let snap = vc
|
||||
.push_snapshot(
|
||||
&project.project_id,
|
||||
PushSnapshotRequest {
|
||||
ciphertext: B64.encode(&ct),
|
||||
nonce: B64.encode(nonce.0),
|
||||
message: Some(msg),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update local .env
|
||||
let env_path = std::env::current_dir()?.join(".env");
|
||||
crate::commands::pull::write_env_file(&env_path, &env_map)?;
|
||||
|
||||
project.latest_version = snap.version;
|
||||
project.save()?;
|
||||
|
||||
println!("Unset {key} → pushed v{}", snap.version);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::Engine as _;
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn default_api_url() -> String {
|
||||
"http://localhost:8081".to_string()
|
||||
}
|
||||
|
||||
fn default_keycloak_url() -> String {
|
||||
"http://localhost:8180/realms/envsync".to_string()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct AuthConfig {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
/// Unix timestamp (seconds since epoch)
|
||||
pub expires_at: i64,
|
||||
pub user_id: String,
|
||||
pub user_email: String,
|
||||
}
|
||||
|
||||
/// X25519 keypair — private key is encrypted with the Argon2id-derived master key.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct KeypairConfig {
|
||||
/// base64 X25519 public key (32 bytes)
|
||||
pub public_key: String,
|
||||
/// base64 encrypted private key ciphertext
|
||||
pub private_key_ct: String,
|
||||
/// base64 nonce used to encrypt the private key (24 bytes)
|
||||
pub privkey_nonce: String,
|
||||
/// base64 Argon2id salt (32 bytes)
|
||||
pub argon2_salt: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_api_url")]
|
||||
pub api_url: String,
|
||||
#[serde(default = "default_keycloak_url")]
|
||||
pub keycloak_url: String,
|
||||
pub auth: Option<AuthConfig>,
|
||||
pub keypair: Option<KeypairConfig>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_url: default_api_url(),
|
||||
keycloak_url: default_keycloak_url(),
|
||||
auth: None,
|
||||
keypair: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn path() -> PathBuf {
|
||||
ProjectDirs::from("com", "envsync", "cli")
|
||||
.expect("Failed to get config directory")
|
||||
.config_dir()
|
||||
.join("config.toml")
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::path();
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read config from {path:?}"))?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn require_auth(&self) -> Result<&AuthConfig> {
|
||||
self.auth
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync login' first."))
|
||||
}
|
||||
|
||||
/// Returns a valid AuthConfig, automatically refreshing the access token if it has
|
||||
/// expired (or will expire within 60 seconds) and a refresh token is available.
|
||||
pub async fn require_valid_auth(&mut self) -> Result<&AuthConfig> {
|
||||
let auth = self
|
||||
.auth
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync login' first."))?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
// Token still valid (with 60-second buffer)
|
||||
if auth.expires_at > now + 60 {
|
||||
return Ok(self.auth.as_ref().unwrap());
|
||||
}
|
||||
|
||||
// Attempt refresh if we have a refresh token
|
||||
let refresh_tok = match auth.refresh_token.clone() {
|
||||
Some(rt) if !rt.is_empty() => rt,
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"Access token expired. Run 'envsync login' to re-authenticate."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let keycloak_url = self.keycloak_url.clone();
|
||||
|
||||
let new_tokens =
|
||||
crate::api::auth::refresh_token(&client, &keycloak_url, "envsync-app-cli", &refresh_tok)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Session expired. Run 'envsync login' to re-authenticate. ({e})"))?;
|
||||
|
||||
// Decode new expiry from JWT
|
||||
let exp = decode_jwt_exp(&new_tokens.access_token).unwrap_or(now + new_tokens.expires_in as i64);
|
||||
|
||||
let existing = self.auth.as_ref().unwrap();
|
||||
self.auth = Some(AuthConfig {
|
||||
access_token: new_tokens.access_token,
|
||||
refresh_token: new_tokens.refresh_token.or_else(|| existing.refresh_token.clone()),
|
||||
expires_at: exp,
|
||||
user_id: existing.user_id.clone(),
|
||||
user_email: existing.user_email.clone(),
|
||||
});
|
||||
self.save()?;
|
||||
|
||||
Ok(self.auth.as_ref().unwrap())
|
||||
}
|
||||
|
||||
pub fn require_keypair(&self) -> Result<&KeypairConfig> {
|
||||
self.keypair
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("No keypair found. Run 'envsync login' first."))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_jwt_exp(token: &str) -> Option<i64> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.ok()?;
|
||||
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
claims["exp"].as_i64()
|
||||
}
|
||||
|
||||
/// Per-repository state stored in `.envsync` (gitignored).
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct LocalProject {
|
||||
pub project_id: String,
|
||||
pub project_name: String,
|
||||
pub environment: String,
|
||||
pub latest_version: u32,
|
||||
}
|
||||
|
||||
impl LocalProject {
|
||||
pub fn path() -> PathBuf {
|
||||
std::env::current_dir()
|
||||
.expect("Failed to get current directory")
|
||||
.join(".envsync")
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::path();
|
||||
if !path.exists() {
|
||||
anyhow::bail!(
|
||||
"No project found in current directory. Run 'envsync init' first."
|
||||
);
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::path();
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use sodiumoxide::crypto::{box_, secretbox};
|
||||
|
||||
use crate::error::EnvSyncError;
|
||||
|
||||
/// Derive a 32-byte vault key from a passphrase using Argon2id.
|
||||
pub fn derive_key(passphrase: &str, salt: &[u8; 32]) -> Result<[u8; 32], EnvSyncError> {
|
||||
let params = Params::new(65536, 3, 1, Some(32))
|
||||
.map_err(|e| EnvSyncError::CryptoError(e.to_string()))?;
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut key = [0u8; 32];
|
||||
argon2
|
||||
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
|
||||
.map_err(|e| EnvSyncError::CryptoError(e.to_string()))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Generate a random 32-byte Argon2id salt.
|
||||
pub fn generate_salt() -> [u8; 32] {
|
||||
let mut salt = [0u8; 32];
|
||||
sodiumoxide::randombytes::randombytes_into(&mut salt);
|
||||
salt
|
||||
}
|
||||
|
||||
/// Generate an X25519 keypair.
|
||||
pub fn generate_keypair() -> (box_::PublicKey, box_::SecretKey) {
|
||||
box_::gen_keypair()
|
||||
}
|
||||
|
||||
/// Encrypt plaintext with XSalsa20-Poly1305. Returns (ciphertext, nonce).
|
||||
pub fn encrypt_vault(
|
||||
plaintext: &[u8],
|
||||
key: &[u8; 32],
|
||||
) -> Result<(Vec<u8>, secretbox::Nonce), EnvSyncError> {
|
||||
let sk = secretbox::Key::from_slice(key)
|
||||
.ok_or_else(|| EnvSyncError::CryptoError("Invalid vault key length".to_string()))?;
|
||||
let nonce = secretbox::gen_nonce();
|
||||
let ciphertext = secretbox::seal(plaintext, &nonce, &sk);
|
||||
Ok((ciphertext, nonce))
|
||||
}
|
||||
|
||||
/// Decrypt ciphertext produced by `encrypt_vault`.
|
||||
pub fn decrypt_vault(
|
||||
ciphertext: &[u8],
|
||||
nonce: &secretbox::Nonce,
|
||||
key: &[u8; 32],
|
||||
) -> Result<Vec<u8>, EnvSyncError> {
|
||||
let sk = secretbox::Key::from_slice(key)
|
||||
.ok_or_else(|| EnvSyncError::CryptoError("Invalid vault key length".to_string()))?;
|
||||
secretbox::open(ciphertext, nonce, &sk)
|
||||
.map_err(|_| EnvSyncError::CryptoError("Decryption failed — wrong passphrase?".to_string()))
|
||||
}
|
||||
|
||||
/// Encrypt an X25519 private key with the master key.
|
||||
pub fn encrypt_privkey(
|
||||
privkey: &box_::SecretKey,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<(Vec<u8>, secretbox::Nonce), EnvSyncError> {
|
||||
encrypt_vault(&privkey.0, master_key)
|
||||
}
|
||||
|
||||
/// Decrypt an X25519 private key with the master key.
|
||||
pub fn decrypt_privkey(
|
||||
ct: &[u8],
|
||||
nonce: &secretbox::Nonce,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<box_::SecretKey, EnvSyncError> {
|
||||
let bytes = decrypt_vault(ct, nonce, master_key)?;
|
||||
box_::SecretKey::from_slice(&bytes)
|
||||
.ok_or_else(|| EnvSyncError::CryptoError("Invalid private key length".to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_vault() {
|
||||
sodiumoxide::init().unwrap();
|
||||
let passphrase = "test-passphrase";
|
||||
let salt = generate_salt();
|
||||
let key = derive_key(passphrase, &salt).unwrap();
|
||||
let plaintext = b"FOO=bar\nDB=postgres://localhost/test";
|
||||
let (ct, nonce) = encrypt_vault(plaintext, &key).unwrap();
|
||||
let decrypted = decrypt_vault(&ct, &nonce, &key).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_passphrase_fails() {
|
||||
sodiumoxide::init().unwrap();
|
||||
let salt = generate_salt();
|
||||
let key = derive_key("correct", &salt).unwrap();
|
||||
let (ct, nonce) = encrypt_vault(b"secret", &key).unwrap();
|
||||
let wrong_key = derive_key("wrong", &salt).unwrap();
|
||||
assert!(decrypt_vault(&ct, &nonce, &wrong_key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_privkey() {
|
||||
sodiumoxide::init().unwrap();
|
||||
let salt = generate_salt();
|
||||
let master_key = derive_key("passphrase", &salt).unwrap();
|
||||
let (_, sec) = generate_keypair();
|
||||
let (ct, nonce) = encrypt_privkey(&sec, &master_key).unwrap();
|
||||
let recovered = decrypt_privkey(&ct, &nonce, &master_key).unwrap();
|
||||
assert_eq!(sec.0, recovered.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum EnvSyncError {
|
||||
#[error("Not authenticated. Run 'envsync login' first.")]
|
||||
NotAuthenticated,
|
||||
|
||||
#[error("No project found. Run 'envsync init' in your project directory.")]
|
||||
NoProject,
|
||||
|
||||
#[error("API error ({status}): {message}")]
|
||||
ApiError { status: u16, message: String },
|
||||
|
||||
#[error("Crypto error: {0}")]
|
||||
CryptoError(String),
|
||||
|
||||
#[error("Config error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[error("Push conflict: remote has newer version. Use --force to override.")]
|
||||
PushConflict,
|
||||
|
||||
#[error("Snapshot version {0} not found.")]
|
||||
SnapshotNotFound(u32),
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Http(#[from] reqwest::Error),
|
||||
}
|
||||
+31
-20
@@ -1,27 +1,38 @@
|
||||
// main.rs
|
||||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
use clap::Parser;
|
||||
|
||||
// Import your authenticate function from the module where you defined it
|
||||
mod api;
|
||||
use api::auth::authenticate;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod error;
|
||||
|
||||
use cli::{Cli, Commands};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let keycloak_url = std::env::var("KEYCLOAK_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8180/realms/envsync".to_string());
|
||||
let client_id = std::env::var("KEYCLOAK_CLIENT_ID")
|
||||
.unwrap_or_else(|_| "envsync-app-cli".to_string());
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Create an HTTP client
|
||||
let client = Client::new();
|
||||
let api_url = cli.api_url.as_deref().unwrap_or("");
|
||||
|
||||
// Call the authentication function and also need to wait fot the user to complete the authentication in the browser
|
||||
let token = authenticate(&client, &keycloak_url, &client_id).await?;
|
||||
let result = match cli.command {
|
||||
Commands::Login => commands::login::run(api_url).await,
|
||||
Commands::Logout => commands::logout::run(),
|
||||
Commands::Init { name, env } => commands::init::run(name, env, api_url).await,
|
||||
Commands::Push { message, force } => commands::push::run(message, force, api_url).await,
|
||||
Commands::Pull { force } => commands::pull::run(force, api_url).await,
|
||||
Commands::Diff { show_values } => commands::diff::run(show_values, api_url).await,
|
||||
Commands::Log { limit } => commands::log::run(limit, api_url).await,
|
||||
Commands::Set { key, value, message } => {
|
||||
commands::set::run(key, value, message, api_url).await
|
||||
}
|
||||
Commands::Unset { key, message } => commands::unset::run(key, message, api_url).await,
|
||||
Commands::Rollback { version, yes } => commands::rollback::run(version, yes, api_url).await,
|
||||
Commands::Status => async { commands::status::run() }.await,
|
||||
};
|
||||
|
||||
println!("Access Token: {}", token);
|
||||
|
||||
Ok(())
|
||||
|
||||
|
||||
}
|
||||
if let Err(e) = result {
|
||||
eprintln!("Error: {e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) redirect("/api/auth/signin/keycloak");
|
||||
|
||||
const username = session.preferredUsername ?? session.user?.email ?? "user";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#050505] text-white">
|
||||
{/* Sidebar */}
|
||||
<aside className="sticky top-0 h-screen w-60 shrink-0 border-r border-white/5 bg-zinc-950 flex flex-col">
|
||||
<div className="flex items-center gap-2 px-5 py-5 border-b border-white/5">
|
||||
<Link href="/" className="flex items-center gap-2 text-base font-bold tracking-tight">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-emerald-500 text-xs font-black text-black">
|
||||
E
|
||||
</span>
|
||||
EnvSync
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
|
||||
<Link
|
||||
href="/dashbaord"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-zinc-400 hover:bg-white/[0.04] hover:text-white transition-colors"
|
||||
>
|
||||
<GridIcon />
|
||||
Projects
|
||||
</Link>
|
||||
<Link
|
||||
href="/api/auth/signout"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-zinc-400 hover:bg-white/[0.04] hover:text-white transition-colors mt-auto"
|
||||
>
|
||||
<LogoutIcon />
|
||||
Sign out
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-white/5 px-4 py-3">
|
||||
<p className="truncate text-xs text-zinc-500">{username}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridIcon() {
|
||||
return (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutIcon() {
|
||||
return (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { createApiClient, type Project } from "@/lib/api";
|
||||
|
||||
const ENV_COLORS: Record<string, string> = {
|
||||
production: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
staging: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||
development: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
};
|
||||
|
||||
function EnvBadge({ env }: { env: string }) {
|
||||
const cls = ENV_COLORS[env.toLowerCase()] ?? "bg-zinc-800 text-zinc-400 border-zinc-700";
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${cls}`}>
|
||||
{env}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Create Project Modal ───────────────────────────────────────────────────
|
||||
|
||||
const ENVIRONMENTS = ["development", "staging", "production", "test"];
|
||||
|
||||
function CreateProjectModal({
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, env: string) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [env, setEnv] = useState("development");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreate(name.trim(), env);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create project");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-6 shadow-2xl">
|
||||
<h2 className="text-lg font-semibold">New Project</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Create a new encrypted vault for your team.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="my-app"
|
||||
className="w-full rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50"
|
||||
autoFocus
|
||||
required
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">
|
||||
Environment
|
||||
</label>
|
||||
<select
|
||||
value={env}
|
||||
onChange={(e) => setEnv(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-white outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50"
|
||||
>
|
||||
{ENVIRONMENTS.map((e) => (
|
||||
<option key={e} value={e}>{e}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !name.trim()}
|
||||
className="flex-1 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? "Creating…" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Delete Confirmation ────────────────────────────────────────────────────
|
||||
|
||||
function DeleteConfirmModal({
|
||||
project,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: {
|
||||
project: Project;
|
||||
onClose: () => void;
|
||||
onDelete: () => Promise<void>;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onDelete();
|
||||
onClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-2xl border border-red-500/20 bg-zinc-950 p-6 shadow-2xl">
|
||||
<h2 className="text-lg font-semibold text-red-400">Delete project?</h2>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
This will permanently delete{" "}
|
||||
<span className="font-medium text-white">{project.name}</span> and all
|
||||
its snapshots. This action cannot be undone.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-400 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Project Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
onDelete,
|
||||
}: {
|
||||
project: Project;
|
||||
onDelete: (p: Project) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative rounded-2xl border border-white/[0.06] bg-zinc-900/40 p-5 hover:border-emerald-500/20 hover:bg-emerald-500/[0.03] transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/dashbaord/projects/${project.id}`}
|
||||
className="block truncate font-semibold tracking-tight hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Created {formatDate(project.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<EnvBadge env={project.environment} />
|
||||
<button
|
||||
onClick={() => onDelete(project)}
|
||||
className="opacity-0 group-hover:opacity-100 rounded-lg p-1.5 text-zinc-600 hover:text-red-400 hover:bg-red-500/10 transition-all"
|
||||
title="Delete project"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Link
|
||||
href={`/dashbaord/projects/${project.id}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.06] px-3 py-1.5 text-xs text-zinc-400 hover:text-white hover:border-white/10 transition-colors"
|
||||
>
|
||||
<HistoryIcon />
|
||||
View snapshots
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryIcon() {
|
||||
return (
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
if (!session?.accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = createApiClient(session.accessToken);
|
||||
const list = await api.listProjects();
|
||||
setProjects(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load projects");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [session?.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
fetchProjects();
|
||||
}
|
||||
}, [status, fetchProjects]);
|
||||
|
||||
async function handleCreate(name: string, env: string) {
|
||||
if (!session?.accessToken) return;
|
||||
const api = createApiClient(session.accessToken);
|
||||
await api.createProject(name, env);
|
||||
await fetchProjects();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!session?.accessToken || !deleteTarget) return;
|
||||
const api = createApiClient(session.accessToken);
|
||||
await api.deleteProject(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
await fetchProjects();
|
||||
}
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{projects.length} project{projects.length !== 1 ? "s" : ""} in your account
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400 transition-colors"
|
||||
>
|
||||
<PlusIcon />
|
||||
New project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CLI hint */}
|
||||
<div className="mt-6 rounded-xl border border-white/[0.06] bg-zinc-900/40 px-4 py-3 font-mono text-xs text-zinc-500">
|
||||
<span className="text-emerald-400">$</span>{" "}
|
||||
envsync init --project <name> --env <env>
|
||||
<span className="ml-4 not-italic font-sans text-zinc-600">← or create via this dashboard</span>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-6 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
<button onClick={fetchProjects} className="ml-3 underline hover:no-underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects grid */}
|
||||
{projects.length === 0 && !error ? (
|
||||
<div className="mt-16 flex flex-col items-center text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/[0.06] bg-zinc-900/60">
|
||||
<FolderIcon />
|
||||
</div>
|
||||
<h3 className="mt-4 font-semibold">No projects yet</h3>
|
||||
<p className="mt-2 max-w-sm text-sm text-zinc-500">
|
||||
Create your first project to start syncing encrypted environment variables across your team.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-black hover:bg-emerald-400 transition-colors"
|
||||
>
|
||||
<PlusIcon />
|
||||
Create first project
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<ProjectCard
|
||||
key={p.id}
|
||||
project={p}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{showCreate && (
|
||||
<CreateProjectModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
)}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
project={deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIcon() {
|
||||
return (
|
||||
<svg className="h-8 w-8 text-zinc-600" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25m19.5 0A2.25 2.25 0 0 1 19.5 16.5h-15a2.25 2.25 0 0 1-2.25-2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 16.91a2.25 2.25 0 0 1-1.07-1.916V14.25" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { createApiClient, type Project, type SnapshotMeta } from "@/lib/api";
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelative(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
const ENV_BADGE: Record<string, string> = {
|
||||
production: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
staging: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||
development: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
};
|
||||
|
||||
// ── Snapshot Row ────────────────────────────────────────────────────────────
|
||||
|
||||
function SnapshotRow({
|
||||
snap,
|
||||
isLatest,
|
||||
}: {
|
||||
snap: SnapshotMeta;
|
||||
isLatest: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 py-4 first:pt-0">
|
||||
{/* Version bubble */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-bold ${
|
||||
isLatest
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
|
||||
: "border-white/10 bg-zinc-800 text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
v{snap.version}
|
||||
</div>
|
||||
{/* connector line — handled by parent spacing */}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isLatest && (
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-400">
|
||||
latest
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm font-medium truncate">
|
||||
{snap.message ?? <span className="italic text-zinc-500">No message</span>}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{snap.pushed_by ? (
|
||||
<>
|
||||
<span className="font-mono text-zinc-400">{snap.pushed_by.split("-")[0]}…</span>
|
||||
{" · "}
|
||||
</>
|
||||
) : null}
|
||||
<span title={formatDate(snap.created_at)}>{formatRelative(snap.created_at)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-xs text-zinc-600" title={formatDate(snap.created_at)}>
|
||||
{formatDate(snap.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CLI Reference Panel ─────────────────────────────────────────────────────
|
||||
|
||||
function CliPanel({ projectId }: { projectId: string }) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
function copy(text: string, key: string) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
const commands = [
|
||||
{ key: "push", cmd: "envsync push -m \"describe changes\"", desc: "Encrypt & push local .env" },
|
||||
{ key: "pull", cmd: "envsync pull", desc: "Pull latest snapshot" },
|
||||
{ key: "diff", cmd: "envsync diff", desc: "Compare local vs remote" },
|
||||
{ key: "log", cmd: "envsync log", desc: "View snapshot history" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-zinc-900/40 p-5">
|
||||
<h2 className="text-sm font-semibold text-zinc-300">CLI Quick Reference</h2>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
Project ID: <span className="font-mono text-zinc-500">{projectId}</span>
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{commands.map(({ key, cmd, desc }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="group flex items-center justify-between gap-3 rounded-lg border border-white/[0.04] bg-zinc-900/60 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<code className="text-xs text-emerald-400">{cmd}</code>
|
||||
<p className="text-xs text-zinc-600 mt-0.5">{desc}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copy(cmd, key)}
|
||||
className="shrink-0 rounded p-1 text-zinc-600 opacity-0 group-hover:opacity-100 hover:text-zinc-300 hover:bg-white/5 transition-all"
|
||||
title="Copy"
|
||||
>
|
||||
{copied === key ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: session, status } = useSession();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [snapshots, setSnapshots] = useState<SnapshotMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!session?.accessToken || !id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = createApiClient(session.accessToken);
|
||||
const [proj, snaps] = await Promise.all([
|
||||
api.getProject(id),
|
||||
api.listSnapshots(id, 50),
|
||||
]);
|
||||
setProject(proj);
|
||||
setSnapshots(snaps);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load project");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [session?.accessToken, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") fetchData();
|
||||
}, [status, fetchData]);
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto">
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
<button onClick={fetchData} className="ml-3 underline hover:no-underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const envClass = ENV_BADGE[project.environment.toLowerCase()] ?? "bg-zinc-800 text-zinc-400 border-zinc-700";
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 mb-6">
|
||||
<Link href="/dashbaord" className="hover:text-white transition-colors">
|
||||
Projects
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">{project.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{project.name}</h1>
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${envClass}`}>
|
||||
{project.environment}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{snapshots.length} snapshot{snapshots.length !== 1 ? "s" : ""} · Created {formatDate(project.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
||||
{/* Snapshot history */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-zinc-900/40">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-zinc-300">Snapshot History</h2>
|
||||
<span className="text-xs text-zinc-600">{snapshots.length} total</span>
|
||||
</div>
|
||||
|
||||
{snapshots.length === 0 ? (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<p className="text-sm text-zinc-500">No snapshots yet.</p>
|
||||
<p className="mt-2 text-xs text-zinc-600">
|
||||
Run{" "}
|
||||
<code className="text-emerald-400">envsync push</code>{" "}
|
||||
to create the first one.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-white/[0.04] px-5">
|
||||
{snapshots.map((snap, i) => (
|
||||
<SnapshotRow
|
||||
key={snap.id}
|
||||
snap={snap}
|
||||
isLatest={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rollback note */}
|
||||
{snapshots.length > 1 && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-xl border border-white/[0.04] bg-zinc-900/20 px-4 py-3">
|
||||
<InfoIcon />
|
||||
<p className="text-xs text-zinc-600">
|
||||
To roll back:{" "}
|
||||
<code className="text-emerald-500">envsync rollback --version <N></code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4">
|
||||
<CliPanel projectId={id} />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-zinc-900/40 p-5 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-zinc-300">Stats</h2>
|
||||
<div className="space-y-2">
|
||||
<Stat label="Total snapshots" value={snapshots.length.toString()} />
|
||||
<Stat
|
||||
label="Latest version"
|
||||
value={snapshots[0] ? `v${snapshots[0].version}` : "—"}
|
||||
/>
|
||||
<Stat
|
||||
label="Last push"
|
||||
value={snapshots[0] ? formatRelative(snapshots[0].created_at) : "—"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-500">{label}</span>
|
||||
<span className="text-xs font-medium text-zinc-300">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg className="h-3.5 w-3.5 text-emerald-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoIcon() {
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0 text-zinc-600" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8081";
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
environment: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SnapshotMeta {
|
||||
id: string;
|
||||
version: number;
|
||||
message: string | null;
|
||||
pushed_by: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SnapshotFull extends SnapshotMeta {
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
status: number;
|
||||
error: string;
|
||||
fields?: Record<string, string>;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private token: string;
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let body: ApiError;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = { status: res.status, error: res.statusText };
|
||||
}
|
||||
const err = new Error(body.error ?? `HTTP ${res.status}`);
|
||||
(err as Error & { apiError: ApiError }).apiError = body;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as unknown as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────────────
|
||||
|
||||
listProjects(): Promise<Project[]> {
|
||||
return this.request<Project[]>("/api/projects");
|
||||
}
|
||||
|
||||
getProject(id: string): Promise<Project> {
|
||||
return this.request<Project>(`/api/projects/${id}`);
|
||||
}
|
||||
|
||||
createProject(name: string, environment: string): Promise<Project> {
|
||||
return this.request<Project>("/api/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, environment }),
|
||||
});
|
||||
}
|
||||
|
||||
deleteProject(id: string): Promise<void> {
|
||||
return this.request<void>(`/api/projects/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ── Snapshots ─────────────────────────────────────────────────────
|
||||
|
||||
listSnapshots(projectId: string, limit = 50): Promise<SnapshotMeta[]> {
|
||||
return this.request<SnapshotMeta[]>(
|
||||
`/api/projects/${projectId}/snapshots?limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
getLatestSnapshot(projectId: string): Promise<SnapshotFull | null> {
|
||||
return this.request<SnapshotFull>(
|
||||
`/api/projects/${projectId}/snapshots/latest`
|
||||
).catch((err) => {
|
||||
if ((err as Error & { apiError?: ApiError }).apiError?.status === 404)
|
||||
return null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
getSnapshot(projectId: string, snapId: string): Promise<SnapshotFull> {
|
||||
return this.request<SnapshotFull>(
|
||||
`/api/projects/${projectId}/snapshots/${snapId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient(token: string): ApiClient {
|
||||
return new ApiClient(token);
|
||||
}
|
||||
@@ -1,14 +1,44 @@
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
async function refreshAccessToken(token: JWT): Promise<JWT> {
|
||||
const issuer = requiredEnv("KEYCLOAK_ISSUER");
|
||||
const tokenUrl = `${issuer}/protocol/openid-connect/token`;
|
||||
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: requiredEnv("KEYCLOAK_CLIENT_ID"),
|
||||
client_secret: requiredEnv("KEYCLOAK_CLIENT_SECRET"),
|
||||
refresh_token: String(token.refreshToken ?? ""),
|
||||
}),
|
||||
});
|
||||
|
||||
const refreshed = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
// Refresh failed — force re-login by returning an error token
|
||||
return { ...token, error: "RefreshAccessTokenError" };
|
||||
}
|
||||
|
||||
return value;
|
||||
return {
|
||||
...token,
|
||||
accessToken: refreshed.access_token,
|
||||
idToken: refreshed.id_token ?? token.idToken,
|
||||
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
||||
// expires_in is seconds from now
|
||||
accessTokenExpiresAt: Math.floor(Date.now() / 1000) + refreshed.expires_in,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
@@ -17,6 +47,7 @@ export const authOptions: NextAuthOptions = {
|
||||
clientId: requiredEnv("KEYCLOAK_CLIENT_ID"),
|
||||
clientSecret: requiredEnv("KEYCLOAK_CLIENT_SECRET"),
|
||||
issuer: requiredEnv("KEYCLOAK_ISSUER"),
|
||||
authorization: { params: { scope: "openid email profile offline_access" } },
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
@@ -24,20 +55,41 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
// Initial sign-in — store everything from the provider
|
||||
if (account) {
|
||||
token.accessToken = account.access_token;
|
||||
token.idToken = account.id_token;
|
||||
token.refreshToken = account.refresh_token;
|
||||
token.accessTokenExpiresAt = account.expires_at; // unix seconds
|
||||
}
|
||||
|
||||
if (profile && typeof profile === "object" && "preferred_username" in profile) {
|
||||
token.preferredUsername = String(profile.preferred_username);
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.preferredUsername = typeof token.preferredUsername === "string" ? token.preferredUsername : undefined;
|
||||
// Token still valid (with 60-second buffer)
|
||||
const expiresAt = typeof token.accessTokenExpiresAt === "number"
|
||||
? token.accessTokenExpiresAt
|
||||
: 0;
|
||||
if (Math.floor(Date.now() / 1000) < expiresAt - 60) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Access token expired — try to refresh
|
||||
return refreshAccessToken(token);
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
session.preferredUsername = typeof token.preferredUsername === "string"
|
||||
? token.preferredUsername
|
||||
: undefined;
|
||||
session.accessToken = typeof token.accessToken === "string"
|
||||
? token.accessToken
|
||||
: undefined;
|
||||
// Expose refresh errors to the client so it can redirect to sign-in
|
||||
if (token.error) {
|
||||
(session as typeof session & { error?: string }).error = token.error as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
Vendored
+4
@@ -3,6 +3,7 @@ import "next-auth/jwt";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
accessToken?: string;
|
||||
preferredUsername?: string;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +12,9 @@ declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
accessToken?: string;
|
||||
idToken?: string;
|
||||
refreshToken?: string;
|
||||
accessTokenExpiresAt?: number; // unix seconds
|
||||
preferredUsername?: string;
|
||||
error?: string;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user