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:
Aryan Singh committed 2026-03-14 18:30:10 -05:00
1 parent 3d72c53262
commit 473e8e01fa
57 files changed
+3888 -281

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
{
"java.compile.nullAnalysis.mode": "automatic"
}
+87
View File
@@ -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`
+179 -106
View File
@@ -7,11 +7,11 @@
[![Next.js](https://img.shields.io/badge/Next.js-15-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-15-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![Spring Boot](https://img.shields.io/badge/Spring_Boot-3.x-6DB33F?style=flat-square&logo=springboot)](https://spring.io/projects/spring-boot) [![Spring Boot](https://img.shields.io/badge/Spring_Boot-3.x-6DB33F?style=flat-square&logo=springboot)](https://spring.io/projects/spring-boot)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![Rust](https://img.shields.io/badge/Rust-CLI-CE422B?style=flat-square&logo=rust)](https://www.rust-lang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15-4169E1?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7-DC382D?style=flat-square&logo=redis)](https://redis.io/) [![Redis](https://img.shields.io/badge/Redis-7-DC382D?style=flat-square&logo=redis)](https://redis.io/)
[![MinIO](https://img.shields.io/badge/MinIO-Object_Storage-C72E49?style=flat-square&logo=minio)](https://min.io/) [![MinIO](https://img.shields.io/badge/MinIO-Object_Storage-C72E49?style=flat-square&logo=minio)](https://min.io/)
[![Keycloak](https://img.shields.io/badge/Keycloak-IAM-4D9B9B?style=flat-square)](https://www.keycloak.org/) [![Keycloak](https://img.shields.io/badge/Keycloak-IAM-4D9B9B?style=flat-square)](https://www.keycloak.org/)
[![NGINX](https://img.shields.io/badge/NGINX-Reverse_Proxy-009639?style=flat-square&logo=nginx)](https://nginx.org/)
[![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?style=flat-square&logo=docker)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?style=flat-square&logo=docker)](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.** > 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 ## ✨ Features
- **CLI-first workflow** — `envsync push`, `envsync pull`, `envsync diff` — feels like git for your `.env` - **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 - **End-to-end encryption** — secrets encrypted client-side with `libsodium` (XSalsa20-Poly1305) before ever hitting the server
- **Real-time notifications** — teammates get notified instantly via Redis pub/sub when variables change - **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` - **Environment scoping** — separate vaults for `development`, `staging`, `production`
- **Audit log** — full history of who changed what, when, and from where - **Web dashboard** — manage projects, browse snapshot history, and copy CLI commands
- **`.env` diff viewer** — visual diff in the web dashboard showing exactly what changed between versions - **Audit trail** — every push records who pushed it and when
- **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
- **Keycloak SSO** — enterprise-grade identity: OIDC login, MFA, LDAP, team provisioning - **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 │ │ CLI Tool (Rust) │
│ envsync push / pull / diff │ │ envsync push / pull / diff / rollback / log │
│ XSalsa20-Poly1305 + Argon2id (client-side) │
└────────────────────────┬────────────────────────────────┘ └────────────────────────┬────────────────────────────────┘
│ HTTPS (via NGINX) │ HTTPS · E2E Encrypted Payload
│ E2E Encrypted Payload
┌────────────────────────▼────────────────────────────────┐ ┌────────────────────────▼────────────────────────────────┐
│ NGINX │ │ Spring Boot API (port 8081) │
│ Reverse Proxy / TLS termination │ │ Auth · Vault CRUD · Snapshot versioning │
└──────────────────────┬──────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────┐
│ Spring Boot API │
│ Auth · Vault CRUD · Audit Logging │
└──────┬──────────────────┬──────────────────┬────────────┘ └──────┬──────────────────┬──────────────────┬────────────┘
│ │ │ │ │ │
┌──────▼──────┐ ┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌───────▼──────┐ ┌──────▼──────┐
│ PostgreSQL │ │ Redis │ │ MinIO │ │ PostgreSQL │ │ Redis │ │ MinIO │
│ Vaults & │ │ Pub/Sub │ │ Encrypted │ │ Vaults & │ │ Pub/Sub │ │ Encrypted │
│ Audit Logs │ │ Notifications│ │ Backups │ │ Snapshots │ │ (planned) │ │ Backups │
└─────────────┘ └──────────────┘ └─────────────┘ └─────────────┘ └──────────────┘ └─────────────┘
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ Keycloak │ │ Keycloak (port 8180) │
│ SSO · OIDC · Role Management · User Provisioning │ │ SSO · OIDC · OAuth2 Device Code Flow │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
│ │
┌────────────────────────▼────────────────────────────────┐ ┌────────────────────────▼────────────────────────────────┐
│ Next.js Dashboard │ │ Next.js Dashboard (port 3000) │
│ Web UI · Real-time updates · Diff viewer │ │ Projects · Snapshot history · CLI guide │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
``` ```
@@ -85,19 +81,17 @@ Environment variable drift causes bugs that are impossible to reproduce, broken
## 🛠️ Tech Stack ## 🛠️ Tech Stack
| Layer | Technology | Self-Hosted Replaces | | Layer | Technology | Version |
|---|---|---| |---|---|---|
| **Frontend** | Next.js 15 (App Router) | — | | **Frontend** | Next.js (App Router) | 15/16 |
| **Backend** | Spring Boot 3 (Java) | — | | **Backend** | Spring Boot | 3.x / Java 21 |
| **Database** | PostgreSQL 16 | AWS RDS | | **CLI** | Rust | 2024 Edition |
| **Cache / Pub-Sub** | Redis 7 | AWS ElastiCache / SQS | | **Database** | PostgreSQL | 15 |
| **Object Storage** | MinIO | AWS S3 | | **Cache / Pub-Sub** | Redis | 7 |
| **Identity / Auth** | Keycloak (OIDC) | AWS IAM / Cognito | | **Object Storage** | MinIO | latest |
| **Reverse Proxy** | NGINX | AWS CloudFront | | **Identity / Auth** | Keycloak (OIDC) | latest |
| **Encryption** | libsodium / TweetNaCl.js | AWS KMS | | **CLI Encryption** | libsodium (sodiumoxide) | XSalsa20-Poly1305 |
| **ORM** | Hibernate / Spring Data JPA | — | | **Key Derivation** | Argon2id | 64 MB, 3 iterations |
| **CLI** | Node.js (npm package) | — |
| **Containerization** | Docker Compose | AWS ECS / Fargate |
--- ---
@@ -106,8 +100,9 @@ Environment variable drift causes bugs that are impossible to reproduce, broken
### Prerequisites ### Prerequisites
- Docker & Docker Compose - Docker & Docker Compose
- Java 21+ - Java 21+ (for backend development)
- Node.js 20+ - Node.js 20+ (for frontend development)
- Rust toolchain (for CLI development)
### 1. Clone & Start Infrastructure ### 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 git clone https://github.com/yourusername/envsync.git
cd envsync cd envsync
docker compose up -d 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 ### 2. Configure Keycloak
``` ```
Open http://localhost:8180 Open http://localhost:8180
Admin console → Create realm: "envsync" Admin credentials: admin / admin
Create client: "envsync-api"
Set redirect URIs: http://localhost:3000/* 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 ### 3. Start the Backend
```
Open http://localhost:9001 (MinIO Console)
Create bucket: "envsync-backups"
Generate access key → paste into backend .env
```
### 4. Start the Backend
```bash ```bash
cd backend cd backend
cp .env.example .env # fill in Keycloak + MinIO credentials
./mvnw spring-boot:run ./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 ```bash
cd frontend cd frontend
cp .env.example .env.local # fill in Keycloak client credentials
npm install npm install
npm run dev npm run dev
# Dashboard at http://localhost:3000 # Dashboard at http://localhost:3000
``` ```
### 6. Install the CLI Required environment variables (`.env.local`):
```bash ```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 ### 5. Build & Use the CLI
envsync init # initialize in your project
envsync push # push your .env to the vault ```bash
envsync pull # pull latest secrets cd cli/envsync-cli
envsync diff # see what changed 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 ## 📖 CLI Reference
```bash ```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 init --project my-app --env development
envsync push # push local .env → vault
envsync pull # pull vault → local .env # Vault operations
envsync diff # diff local vs vault envsync push # encrypt & push local .env → vault
envsync log # view audit log envsync push -m "add stripe webhook" # with a commit message
envsync set DATABASE_URL "postgres://..." 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 unset OLD_API_KEY
envsync envs # list environments (dev/staging/prod)
envsync history # version history of the vault # History
envsync rollback --version 12 # restore a previous vault version envsync rollback --version 12 # restore vault to v12 content (creates new snapshot)
envsync rollback --version 12 --yes # skip confirmation
``` ```
--- **Global flags:**
## 📁 Project Structure ```bash
envsync --api-url http://my-server:8081 <command>
``` # or: export ENVSYNC_API_URL=http://my-server:8081
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
``` ```
--- ---
@@ -223,11 +220,87 @@ envsync/
EnvSync uses a **zero-knowledge encryption** model: EnvSync uses a **zero-knowledge encryption** model:
1. Secret key derived locally from your password using **Argon2** 1. **Key derivation** — your passphrase → 32-byte secret key via Argon2id (64 MB, 3 iterations, 1 thread)
2. Secrets encrypted **before** leaving your machine using **XChaCha20-Poly1305** 2. **Client-side encrypt** — `.env` → JSON → XSalsa20-Poly1305 encryption with a random nonce; ciphertext is base64-encoded
3. Server only ever stores ciphertext — it physically cannot read your secrets 3. **Server stores ciphertext only** — PostgreSQL never sees plaintext; the server physically cannot read your secrets
4. Team sharing via asymmetric encryption — each member's public key wraps the vault key 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, and social login out of the box 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) { public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, 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; package com.envsync.backend;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 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.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 @Configuration
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { 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 @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth http
.anyRequest().authenticated() .cors(cors -> cors.configurationSource(corsConfigurationSource()))
) // Stateless REST API — no CSRF needed (bearer-token auth, no cookies)
.oauth2ResourceServer(oauth2 -> oauth2 .csrf(csrf -> csrf.disable())
.jwt(Customizer.withDefaults()) .authorizeHttpRequests(auth -> auth
); .anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder()))
);
return http.build(); 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; package com.envsync.backend;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; 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 @SpringBootTest
@Disabled("Requires running Docker Compose stack (PostgreSQL + Keycloak). Run with integration profile.")
class BackendApplicationTests { class BackendApplicationTests {
@Test @Test
void contextLoads() { 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);
}
}
BIN
View File
Binary file not shown.
+122 -2
View File
@@ -67,6 +67,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -107,12 +119,36 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 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]] [[package]]
name = "block2" name = "block2"
version = "0.6.2" version = "0.6.2"
@@ -184,6 +220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive",
] ]
[[package]] [[package]]
@@ -198,6 +235,18 @@ dependencies = [
"strsim", "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]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "1.0.0" version = "1.0.0"
@@ -279,6 +328,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.29.0" version = "0.29.0"
@@ -302,6 +360,16 @@ dependencies = [
"winapi", "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]] [[package]]
name = "ctrlc" name = "ctrlc"
version = "3.5.2" version = "3.5.2"
@@ -325,6 +393,17 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "directories" name = "directories"
version = "6.0.0" version = "6.0.0"
@@ -419,6 +498,8 @@ name = "envsync-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2",
"base64",
"chrono", "chrono",
"clap", "clap",
"comfy-table", "comfy-table",
@@ -534,6 +615,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -1137,6 +1228,17 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -1282,7 +1384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha",
"rand_core", "rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1292,9 +1394,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -2016,6 +2124,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -2082,6 +2196,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
+3 -1
View File
@@ -5,7 +5,9 @@ edition = "2024"
[dependencies] [dependencies]
anyhow = "1.0.102" 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" comfy-table = "7.2.2"
console = "0.16.2" console = "0.16.2"
ctrlc = "3.5.2" ctrlc = "3.5.2"
+54 -26
View File
@@ -1,4 +1,4 @@
use anyhow::Result; use anyhow::{anyhow, Result};
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration; use std::time::Duration;
@@ -7,7 +7,6 @@ use tokio::time::sleep;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct DeviceAuthRequest { struct DeviceAuthRequest {
client_id: String, client_id: String,
client_secret: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -20,59 +19,88 @@ struct DeviceAuthResponse {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] pub struct TokenResponse {
struct TokenResponse { pub access_token: String,
access_token: String, pub refresh_token: Option<String>,
refresh_token: Option<String>, pub expires_in: u64,
expires_in: u64,
} }
pub async fn authenticate(client: &Client, keycloak_url: &str, client_id: &str) -> Result<String> { /// Exchange a refresh token for a new access token.
// 1. Request device code pub async fn refresh_token(
let device_auth_url = format!("{}/protocol/openid-connect/auth/device", keycloak_url); 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 = [ let params = [
("grant_type", "refresh_token"),
("client_id", client_id), ("client_id", client_id),
("refresh_token", refresh_token),
]; ];
let resp = client.post(&token_url).form(&params).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(&params).send().await?; let response = client.post(&device_auth_url).form(&params).send().await?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); 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?; 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); println!("Enter the code: {}", resp.user_code);
// 2. Poll for token let token_url = format!("{keycloak_url}/protocol/openid-connect/token");
let token_url = format!("{}/protocol/openid-connect/token", keycloak_url); let interval = Duration::from_secs(resp.interval.max(5));
let interval = Duration::from_secs(resp.interval);
let start = std::time::Instant::now(); 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 = [ let params = [
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", &resp.device_code), ("device_code", &resp.device_code),
("client_id", client_id), ("client_id", client_id),
]; ];
let token_resp = client.post(&token_url).form(&params).send().await?; let token_resp = client.post(&token_url).form(&params).send().await?;
let status = token_resp.status(); let status = token_resp.status();
let text = token_resp.text().await.unwrap_or_default(); let text = token_resp.text().await.unwrap_or_default();
println!("Token response status: {}, body: {}", status, text);
if status.is_success() { if status.is_success() {
let token: TokenResponse = serde_json::from_str(&text)?; let token: TokenResponse = serde_json::from_str(&text)?;
return Ok(token.access_token); return Ok(token);
} else if status.as_u16() == 400 && text.contains("authorization_pending") { } else if status.as_u16() == 400
sleep(interval).await; && (text.contains("authorization_pending") || text.contains("slow_down"))
{
continue;
} else { } else {
anyhow::bail!("Unexpected token response: {} - {}", status, text); anyhow::bail!("Token request failed ({status}): {text}");
} }
} }
}
anyhow::bail!("Authentication timed out");
}
+1
View File
@@ -1 +1,2 @@
pub mod auth; pub mod auth;
pub mod vault;
+190
View File
@@ -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(())
}
}
+95
View File
@@ -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,
}
+106
View File
@@ -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(())
}
+76 -105
View File
@@ -1,118 +1,89 @@
use anyhow::{Result, Context, anyhow}; use anyhow::Result;
use directories::ProjectDirs; use dialoguer::{Confirm, Input};
use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf};
use sodiumoxide::crypto::box__;
#[derive(Serialize, Deserialize, Debug, Clone)] use crate::api::vault::VaultClient;
pub struct AuthConfig { use crate::config::{Config, LocalProject};
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: chrono::DateTime<chrono::Utc>,
pub user_id: String,
pub user_email: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)] pub async fn run(
pub struct KeypairConfig { name: Option<String>,
pub public_key: String, // base64 encoded env: Option<String>,
pub private_key: String, // base64 encrypted with a passphrase? 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)] // Determine defaults from current directory
pub struct VaultContext { let cwd = std::env::current_dir()?;
pub project_id: String, let default_name = cwd
pub project_name: String, .file_name()
pub environment: String, .and_then(|n| n.to_str())
pub vault_key: Option<String>, // base64 encrypted with user's public key .unwrap_or("my-project")
} .to_string();
#[derive(Serialize, Deserialize, Debug, Default)] let project_name: String = if let Some(n) = name {
pub struct Config { n
pub api_url: String, } else {
pub auth: Option<AuthConfig>, Input::new()
pub keypair: Option<KeypairConfig>, .with_prompt("Project name")
pub current_context: Option<VaultContext>, .default(default_name)
} .interact_text()?
};
impl Config { let environment: String = if let Some(e) = env {
pub fn path() -> PathBuf { e
ProjectDirs::from("com", "envsync", "cli") } else {
.expect("Failed to get config directory") Input::new()
.config_dir() .with_prompt("Environment")
.join("config.toml") .default("development".to_string())
} .interact_text()?
};
pub fn load() -> Result<Self> { println!("Creating project '{project_name}' ({environment})...");
let path = Self::path();
if path.exists() { // Check .envsync doesn't already exist
let content = fs::read_to_string(&path) if LocalProject::path().exists() {
.with_context(|| format!("Failed to read config from {:?}", path))?; let overwrite = Confirm::new()
Ok(toml::from_str(&content)?) .with_prompt("A .envsync file already exists. Re-initialize?")
} else { .default(false)
Ok(Config::default()) .interact()?;
if !overwrite {
println!("Aborted.");
return Ok(());
} }
} }
pub fn save(&self) -> Result<()> { let vc = VaultClient::new(effective_api_url, &auth.access_token);
let path = Self::path(); let project = vc.create_project(&project_name, &environment).await?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; 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)?; } else {
fs::write(&path, content)?; fs::write(&path, ".envsync\n")?;
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);
} }
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,
})
}
}
+47
View File
@@ -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(())
}
+78
View File
@@ -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(())
}
+26
View File
@@ -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(())
}
+11
View File
@@ -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;
+88
View File
@@ -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(())
}
+86
View File
@@ -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)
}
+96
View File
@@ -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(())
}
+74
View File
@@ -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(())
}
+67
View File
@@ -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(())
}
+78
View File
@@ -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(())
}
+199
View File
@@ -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(())
}
}
+109
View File
@@ -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);
}
}
+34
View File
@@ -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
View File
@@ -1,27 +1,38 @@
// main.rs use clap::Parser;
use anyhow::Result;
use reqwest::Client;
// Import your authenticate function from the module where you defined it
mod api; mod api;
use api::auth::authenticate; mod cli;
mod commands;
mod config;
mod crypto;
mod error;
use cli::{Cli, Commands};
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() {
let keycloak_url = std::env::var("KEYCLOAK_URL") let cli = Cli::parse();
.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());
// Create an HTTP client let api_url = cli.api_url.as_deref().unwrap_or("");
let client = Client::new();
// Call the authentication function and also need to wait fot the user to complete the authentication in the browser let result = match cli.command {
let token = authenticate(&client, &keycloak_url, &client_id).await?; 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); if let Err(e) = result {
eprintln!("Error: {e:#}");
Ok(()) std::process::exit(1);
}
}
}
+73
View File
@@ -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>
);
}
+398
View File
@@ -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 &lt;name&gt; --env &lt;env&gt;
<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 &lt;N&gt;</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>
);
}
+113
View File
@@ -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);
}
+59 -7
View File
@@ -1,14 +1,44 @@
import type { NextAuthOptions } from "next-auth"; import type { NextAuthOptions } from "next-auth";
import type { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak"; import KeycloakProvider from "next-auth/providers/keycloak";
function requiredEnv(name: string): string { function requiredEnv(name: string): string {
const value = process.env[name]; const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
if (!value) { async function refreshAccessToken(token: JWT): Promise<JWT> {
throw new Error(`Missing required environment variable: ${name}`); 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 = { export const authOptions: NextAuthOptions = {
@@ -17,6 +47,7 @@ export const authOptions: NextAuthOptions = {
clientId: requiredEnv("KEYCLOAK_CLIENT_ID"), clientId: requiredEnv("KEYCLOAK_CLIENT_ID"),
clientSecret: requiredEnv("KEYCLOAK_CLIENT_SECRET"), clientSecret: requiredEnv("KEYCLOAK_CLIENT_SECRET"),
issuer: requiredEnv("KEYCLOAK_ISSUER"), issuer: requiredEnv("KEYCLOAK_ISSUER"),
authorization: { params: { scope: "openid email profile offline_access" } },
}), }),
], ],
session: { session: {
@@ -24,20 +55,41 @@ export const authOptions: NextAuthOptions = {
}, },
callbacks: { callbacks: {
async jwt({ token, account, profile }) { async jwt({ token, account, profile }) {
// Initial sign-in — store everything from the provider
if (account) { if (account) {
token.accessToken = account.access_token; token.accessToken = account.access_token;
token.idToken = account.id_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) { if (profile && typeof profile === "object" && "preferred_username" in profile) {
token.preferredUsername = String(profile.preferred_username); token.preferredUsername = String(profile.preferred_username);
} }
return token; // Token still valid (with 60-second buffer)
}, const expiresAt = typeof token.accessTokenExpiresAt === "number"
async session({ session, token }) { ? token.accessTokenExpiresAt
session.preferredUsername = typeof token.preferredUsername === "string" ? token.preferredUsername : undefined; : 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; return session;
}, },
}, },
+4
View File
@@ -3,6 +3,7 @@ import "next-auth/jwt";
declare module "next-auth" { declare module "next-auth" {
interface Session { interface Session {
accessToken?: string;
preferredUsername?: string; preferredUsername?: string;
} }
} }
@@ -11,6 +12,9 @@ declare module "next-auth/jwt" {
interface JWT { interface JWT {
accessToken?: string; accessToken?: string;
idToken?: string; idToken?: string;
refreshToken?: string;
accessTokenExpiresAt?: number; // unix seconds
preferredUsername?: string; preferredUsername?: string;
error?: string;
} }
} }