Add backend API for projects & snapshots
Introduce full backend API surface for projects, snapshots and user pubkeys: controllers, DTOs, JPA entities, repositories, services, and tests. Add SecurityConfig improvements (JWT decoder, audience/issuer/timestamp validation and CORS), a GlobalExceptionHandler for consistent error responses, and remove a stray test call from BackendApplication. Also update README (architecture, docs, CLI/Frontend/Backend notes), add CLAUDE.md, VSCode settings, and multiple frontend/CLI Rust changes. Small housekeeping: add .DS_Store and test adjustments.
This commit is contained in:
1 parent
3d72c53262
commit
473e8e01fa
57 files changed
+3886
-279
No files matched your search
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"java.compile.nullAnalysis.mode": "automatic"
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
EnvSync is a self-hosted, zero-knowledge encrypted environment variable management system — think git for `.env` files. It consists of three components:
|
||||||
|
|
||||||
|
- **Backend** — Spring Boot 3 REST API (Java 21)
|
||||||
|
- **Frontend** — Next.js 15 web dashboard (TypeScript)
|
||||||
|
- **CLI** — Rust-based command-line tool (`cli/envsync-cli/`)
|
||||||
|
|
||||||
|
## Development Infrastructure
|
||||||
|
|
||||||
|
All dependent services run via Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # PostgreSQL 15, Redis 7, MinIO, Keycloak (port 8180), pgAdmin (port 5050)
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (Spring Boot + Maven)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
./mvnw spring-boot:run # Dev server on port 8081
|
||||||
|
./mvnw test # Run tests
|
||||||
|
./mvnw clean package # Build JAR
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (Next.js)
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # Dev server on port 3000
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `frontend/.env.example` to `frontend/.env.local` and fill in Keycloak credentials before running.
|
||||||
|
|
||||||
|
### CLI (Rust)
|
||||||
|
```bash
|
||||||
|
cd cli/envsync-cli
|
||||||
|
cargo build # Debug build
|
||||||
|
cargo build --release # Release build
|
||||||
|
cargo test # Run tests
|
||||||
|
cargo clippy # Lint
|
||||||
|
cargo fmt # Format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Request Flow
|
||||||
|
```
|
||||||
|
CLI / Browser
|
||||||
|
→ NGINX (TLS termination)
|
||||||
|
→ Spring Boot API (port 8081)
|
||||||
|
→ PostgreSQL (vaults, audit logs)
|
||||||
|
→ Redis (pub/sub for real-time notifications)
|
||||||
|
→ MinIO (encrypted vault snapshots, S3-compatible)
|
||||||
|
→ Keycloak (OIDC/JWT validation, port 8180)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- **CLI** uses OAuth2 device code flow against Keycloak (`cli/envsync-cli/src/api/auth.rs`)
|
||||||
|
- **Frontend** uses NextAuth with Keycloak OIDC provider (`frontend/src/lib/auth.ts`)
|
||||||
|
- **Backend** is a Spring Security OAuth2 resource server — validates JWTs from Keycloak (`backend/src/main/java/.../SecurityConfig.java`)
|
||||||
|
- Keycloak realm: `envsync`
|
||||||
|
|
||||||
|
### Zero-Knowledge Encryption (CLI)
|
||||||
|
All encryption is client-side before data reaches the server:
|
||||||
|
- Key derivation: Argon2id
|
||||||
|
- Encryption: XChaCha20-Poly1305 (via `sodiumoxide`/libsodium)
|
||||||
|
- Team sharing: vault key wrapped per-member with asymmetric encryption
|
||||||
|
- Server only stores ciphertext
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
Implemented in `cli/envsync-cli/src/commands/`: `init`, `push`, `pull`, `diff`, `log`, `set`, `unset`, `rollback`
|
||||||
|
|
||||||
|
### Backend Config
|
||||||
|
Key settings in `backend/src/main/resources/application.properties`:
|
||||||
|
- PostgreSQL: `localhost:5432` (user/pass: `admin`/`admin`)
|
||||||
|
- Redis: `localhost:6379`
|
||||||
|
- MinIO: `http://localhost:9000` (user/pass: `minioadmin`/`minioadmin`)
|
||||||
|
- Keycloak JWKS: `http://localhost:8180/realms/envsync`
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://spring.io/projects/spring-boot)
|
[](https://spring.io/projects/spring-boot)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.rust-lang.org/)
|
||||||
|
[](https://www.postgresql.org/)
|
||||||
[](https://redis.io/)
|
[](https://redis.io/)
|
||||||
[](https://min.io/)
|
[](https://min.io/)
|
||||||
[](https://www.keycloak.org/)
|
[](https://www.keycloak.org/)
|
||||||
[](https://nginx.org/)
|
|
||||||
[](https://www.docker.com/)
|
[](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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
Generated
+122
-2
@@ -67,6 +67,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(¶ms).send().await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.unwrap_or_default();
|
||||||
|
|
||||||
|
if status.is_success() {
|
||||||
|
Ok(serde_json::from_str(&text)
|
||||||
|
.map_err(|e| anyhow!("Failed to parse refresh response: {e}"))?)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Token refresh failed ({status}): {text}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authenticate(
|
||||||
|
client: &Client,
|
||||||
|
keycloak_url: &str,
|
||||||
|
client_id: &str,
|
||||||
|
) -> Result<TokenResponse> {
|
||||||
|
let device_auth_url = format!("{keycloak_url}/protocol/openid-connect/auth/device");
|
||||||
|
|
||||||
|
let params = [("client_id", client_id)];
|
||||||
let response = client.post(&device_auth_url).form(¶ms).send().await?;
|
let response = client.post(&device_auth_url).form(¶ms).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(¶ms).send().await?;
|
let token_resp = client.post(&token_url).form(¶ms).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 +1,2 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod vault;
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::EnvSyncError;
|
||||||
|
|
||||||
|
// ── Request types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CreateProjectRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub environment: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PushSnapshotRequest {
|
||||||
|
pub ciphertext: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RegisterPubkeyRequest {
|
||||||
|
public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ProjectResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub environment: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct SnapshotMetaResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub version: u32,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct SnapshotFullResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub version: u32,
|
||||||
|
pub ciphertext: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct VaultClient {
|
||||||
|
client: Client,
|
||||||
|
api_url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultClient {
|
||||||
|
pub fn new(api_url: impl Into<String>, token: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
api_url: api_url.into(),
|
||||||
|
token: token.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check(&self, resp: reqwest::Response) -> Result<reqwest::Response, EnvSyncError> {
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let message = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(EnvSyncError::ApiError { status, message });
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_project(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
environment: &str,
|
||||||
|
) -> Result<ProjectResponse, EnvSyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/projects", self.api_url))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.json(&CreateProjectRequest {
|
||||||
|
name: name.to_string(),
|
||||||
|
environment: environment.to_string(),
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(self.check(resp).await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_projects(&self) -> Result<Vec<ProjectResponse>, EnvSyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/projects", self.api_url))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(self.check(resp).await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn push_snapshot(
|
||||||
|
&self,
|
||||||
|
project_id: &str,
|
||||||
|
req: PushSnapshotRequest,
|
||||||
|
) -> Result<SnapshotMetaResponse, EnvSyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/projects/{project_id}/snapshots", self.api_url))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(self.check(resp).await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_latest_snapshot(
|
||||||
|
&self,
|
||||||
|
project_id: &str,
|
||||||
|
) -> Result<Option<SnapshotFullResponse>, EnvSyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!(
|
||||||
|
"{}/api/projects/{project_id}/snapshots/latest",
|
||||||
|
self.api_url
|
||||||
|
))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(self.check(resp).await?.json().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_snapshots(
|
||||||
|
&self,
|
||||||
|
project_id: &str,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<SnapshotMetaResponse>, EnvSyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!(
|
||||||
|
"{}/api/projects/{project_id}/snapshots?limit={limit}",
|
||||||
|
self.api_url
|
||||||
|
))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(self.check(resp).await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_snapshot(
|
||||||
|
&self,
|
||||||
|
project_id: &str,
|
||||||
|
snap_id: &str,
|
||||||
|
) -> Result<SnapshotFullResponse, EnvSyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!(
|
||||||
|
"{}/api/projects/{project_id}/snapshots/{snap_id}",
|
||||||
|
self.api_url
|
||||||
|
))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(self.check(resp).await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register_pubkey(&self, public_key: &str) -> Result<(), EnvSyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.put(format!("{}/api/users/me/pubkey", self.api_url))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.json(&RegisterPubkeyRequest {
|
||||||
|
public_key: public_key.to_string(),
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
self.check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "envsync",
|
||||||
|
about = "Zero-knowledge encrypted environment variable manager",
|
||||||
|
version
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// API server URL (overrides config, env: ENVSYNC_API_URL)
|
||||||
|
#[arg(long, env = "ENVSYNC_API_URL", global = true)]
|
||||||
|
pub api_url: Option<String>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
/// Authenticate with EnvSync via device code flow
|
||||||
|
Login,
|
||||||
|
|
||||||
|
/// Log out and clear stored credentials
|
||||||
|
Logout,
|
||||||
|
|
||||||
|
/// Initialize a new project in the current directory
|
||||||
|
Init {
|
||||||
|
/// Project name (defaults to directory name)
|
||||||
|
#[arg(long, short)]
|
||||||
|
name: Option<String>,
|
||||||
|
/// Environment (e.g. development, staging, production)
|
||||||
|
#[arg(long, short, default_value = "development")]
|
||||||
|
env: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Encrypt and push the local .env to the server
|
||||||
|
Push {
|
||||||
|
/// Commit message describing this snapshot
|
||||||
|
#[arg(long, short)]
|
||||||
|
message: Option<String>,
|
||||||
|
/// Force push even when behind remote version
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Decrypt and pull the latest .env snapshot from the server
|
||||||
|
Pull {
|
||||||
|
/// Overwrite local .env without prompting
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show diff between local .env and latest remote snapshot
|
||||||
|
Diff {
|
||||||
|
/// Print actual values instead of masking with ***
|
||||||
|
#[arg(long)]
|
||||||
|
show_values: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show snapshot history for this project
|
||||||
|
Log {
|
||||||
|
/// Maximum number of entries to display
|
||||||
|
#[arg(long, short, default_value = "20")]
|
||||||
|
limit: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Set a key in the vault and push a new snapshot
|
||||||
|
Set {
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
/// Commit message
|
||||||
|
#[arg(long, short)]
|
||||||
|
message: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Remove a key from the vault and push a new snapshot
|
||||||
|
Unset {
|
||||||
|
key: String,
|
||||||
|
/// Commit message
|
||||||
|
#[arg(long, short)]
|
||||||
|
message: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Roll back the vault to a specific snapshot version
|
||||||
|
Rollback {
|
||||||
|
/// Version number to restore
|
||||||
|
version: u32,
|
||||||
|
/// Skip confirmation prompt
|
||||||
|
#[arg(long, short)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show current auth status and project configuration
|
||||||
|
Status,
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use console::style;
|
||||||
|
use dialoguer::Password;
|
||||||
|
use sodiumoxide::crypto::secretbox;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::api::vault::VaultClient;
|
||||||
|
use crate::config::{Config, LocalProject};
|
||||||
|
use crate::crypto;
|
||||||
|
|
||||||
|
pub async fn run(show_values: bool, api_url: &str) -> Result<()> {
|
||||||
|
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||||
|
|
||||||
|
let config = Config::load()?;
|
||||||
|
let auth = config.require_auth()?;
|
||||||
|
let keypair = config.require_keypair()?;
|
||||||
|
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||||
|
let project = LocalProject::load()?;
|
||||||
|
|
||||||
|
// Load local .env
|
||||||
|
let env_path = std::env::current_dir()?.join(".env");
|
||||||
|
let local_map: BTreeMap<String, String> = if env_path.exists() {
|
||||||
|
let mut m = BTreeMap::new();
|
||||||
|
for item in dotenvy::from_path_iter(&env_path)? {
|
||||||
|
let (k, v) = item?;
|
||||||
|
m.insert(k, v);
|
||||||
|
}
|
||||||
|
m
|
||||||
|
} else {
|
||||||
|
BTreeMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch remote
|
||||||
|
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||||
|
let remote_map = match vc.get_latest_snapshot(&project.project_id).await? {
|
||||||
|
None => {
|
||||||
|
println!("No remote snapshot found. All local keys are new:");
|
||||||
|
for k in local_map.keys() {
|
||||||
|
let display = if show_values {
|
||||||
|
local_map[k].as_str().to_string()
|
||||||
|
} else {
|
||||||
|
"***".to_string()
|
||||||
|
};
|
||||||
|
println!(" {}", style(format!("+{k}={display}")).green());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Some(snap) => {
|
||||||
|
let passphrase = Password::new()
|
||||||
|
.with_prompt("Passphrase")
|
||||||
|
.interact()?;
|
||||||
|
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||||
|
let salt: [u8; 32] = salt_bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||||
|
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||||
|
|
||||||
|
let ciphertext = B64.decode(&snap.ciphertext)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||||
|
let nonce_bytes = B64.decode(&snap.nonce)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||||
|
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||||
|
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||||
|
|
||||||
|
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
|
||||||
|
let m: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
|
||||||
|
m
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let all_keys: std::collections::BTreeSet<&String> =
|
||||||
|
local_map.keys().chain(remote_map.keys()).collect();
|
||||||
|
|
||||||
|
let mut has_diff = false;
|
||||||
|
for key in all_keys {
|
||||||
|
match (local_map.get(key), remote_map.get(key)) {
|
||||||
|
(Some(lv), Some(rv)) if lv == rv => {} // unchanged
|
||||||
|
(Some(lv), Some(rv)) => {
|
||||||
|
has_diff = true;
|
||||||
|
if show_values {
|
||||||
|
println!(" {}", style(format!("~{key}: {rv} → {lv}")).yellow());
|
||||||
|
} else {
|
||||||
|
println!(" {}", style(format!("~{key}")).yellow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(lv), None) => {
|
||||||
|
has_diff = true;
|
||||||
|
let display = if show_values { lv.as_str() } else { "***" };
|
||||||
|
println!(" {}", style(format!("+{key}={display}")).green());
|
||||||
|
}
|
||||||
|
(None, Some(rv)) => {
|
||||||
|
has_diff = true;
|
||||||
|
let display = if show_values { rv.as_str() } else { "***" };
|
||||||
|
println!(" {}", style(format!("-{key}={display}")).red());
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_diff {
|
||||||
|
println!("No differences — local matches remote.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,118 +1,89 @@
|
|||||||
use anyhow::{Result, Context, anyhow};
|
use 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use comfy_table::{Cell, Color, Table};
|
||||||
|
|
||||||
|
use crate::api::vault::VaultClient;
|
||||||
|
use crate::config::{Config, LocalProject};
|
||||||
|
|
||||||
|
pub async fn run(limit: u32, api_url: &str) -> Result<()> {
|
||||||
|
let config = Config::load()?;
|
||||||
|
let auth = config.require_auth()?;
|
||||||
|
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||||
|
let project = LocalProject::load()?;
|
||||||
|
|
||||||
|
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||||
|
let snapshots = vc.list_snapshots(&project.project_id, limit).await?;
|
||||||
|
|
||||||
|
if snapshots.is_empty() {
|
||||||
|
println!("No snapshots found for '{}'.", project.project_name);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut table = Table::new();
|
||||||
|
table.set_header(vec!["", "Version", "Date", "Message"]);
|
||||||
|
|
||||||
|
for snap in &snapshots {
|
||||||
|
let current_marker = if snap.version == project.latest_version {
|
||||||
|
"*"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let msg = snap.message.as_deref().unwrap_or("-");
|
||||||
|
let version_cell = if snap.version == project.latest_version {
|
||||||
|
Cell::new(snap.version).fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Cell::new(snap.version)
|
||||||
|
};
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new(current_marker),
|
||||||
|
version_cell,
|
||||||
|
Cell::new(&snap.created_at),
|
||||||
|
Cell::new(msg),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Project: {} ({})", project.project_name, project.environment);
|
||||||
|
println!("{table}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
|
use dialoguer::Password;
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use crate::api::auth;
|
||||||
|
use crate::api::vault::VaultClient;
|
||||||
|
use crate::config::{AuthConfig, Config, KeypairConfig};
|
||||||
|
use crate::crypto;
|
||||||
|
|
||||||
|
pub async fn run(api_url: &str) -> Result<()> {
|
||||||
|
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||||
|
|
||||||
|
let mut config = Config::load()?;
|
||||||
|
let keycloak_url = config.keycloak_url.clone();
|
||||||
|
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||||
|
|
||||||
|
println!("Authenticating with EnvSync...");
|
||||||
|
let client = Client::new();
|
||||||
|
let token_resp = auth::authenticate(&client, &keycloak_url, "envsync-app-cli").await?;
|
||||||
|
|
||||||
|
// Decode JWT payload (middle segment, URL-safe base64 without padding)
|
||||||
|
let parts: Vec<&str> = token_resp.access_token.split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
anyhow::bail!("Invalid JWT format received from Keycloak");
|
||||||
|
}
|
||||||
|
let payload_bytes = URL_SAFE_NO_PAD
|
||||||
|
.decode(parts[1])
|
||||||
|
.map_err(|e| anyhow!("Failed to decode JWT payload: {e}"))?;
|
||||||
|
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes)?;
|
||||||
|
|
||||||
|
let user_id = claims["sub"].as_str().unwrap_or("").to_string();
|
||||||
|
let user_email = claims["email"].as_str().unwrap_or("").to_string();
|
||||||
|
let exp = claims["exp"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
|
// Generate keypair on first login
|
||||||
|
let keypair_config = if config.keypair.is_none() {
|
||||||
|
println!("Generating a new encryption keypair...");
|
||||||
|
let passphrase = Password::new()
|
||||||
|
.with_prompt("Enter a passphrase to protect your private key")
|
||||||
|
.with_confirmation("Confirm passphrase", "Passphrases do not match")
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
let salt = crypto::generate_salt();
|
||||||
|
let master_key = crypto::derive_key(&passphrase, &salt)?;
|
||||||
|
let (pub_key, sec_key) = crypto::generate_keypair();
|
||||||
|
let (ct, nonce) = crypto::encrypt_privkey(&sec_key, &master_key)?;
|
||||||
|
|
||||||
|
KeypairConfig {
|
||||||
|
public_key: B64.encode(pub_key.0),
|
||||||
|
private_key_ct: B64.encode(&ct),
|
||||||
|
privkey_nonce: B64.encode(nonce.0),
|
||||||
|
argon2_salt: B64.encode(salt),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Using existing keypair.");
|
||||||
|
config.keypair.clone().unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register public key with the backend
|
||||||
|
let vc = VaultClient::new(effective_api_url, &token_resp.access_token);
|
||||||
|
if let Err(e) = vc.register_pubkey(&keypair_config.public_key).await {
|
||||||
|
eprintln!("Warning: failed to register public key with server: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
config.auth = Some(AuthConfig {
|
||||||
|
access_token: token_resp.access_token,
|
||||||
|
refresh_token: token_resp.refresh_token,
|
||||||
|
expires_at: exp,
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
user_email: user_email.clone(),
|
||||||
|
});
|
||||||
|
config.keypair = Some(keypair_config);
|
||||||
|
config.save()?;
|
||||||
|
|
||||||
|
println!("Logged in as {user_email} (sub: {user_id})");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
pub fn run() -> Result<()> {
|
||||||
|
let mut config = Config::load()?;
|
||||||
|
|
||||||
|
if config.auth.is_none() {
|
||||||
|
println!("Not currently logged in.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = config
|
||||||
|
.auth
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.user_email.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
config.auth = None;
|
||||||
|
// Keep the keypair — user can re-authenticate without re-generating keys.
|
||||||
|
config.save()?;
|
||||||
|
|
||||||
|
println!("Logged out ({email}).");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pub mod diff;
|
||||||
|
pub mod init;
|
||||||
|
pub mod log;
|
||||||
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
|
pub mod pull;
|
||||||
|
pub mod push;
|
||||||
|
pub mod rollback;
|
||||||
|
pub mod set;
|
||||||
|
pub mod status;
|
||||||
|
pub mod unset;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use dialoguer::{Confirm, Password};
|
||||||
|
use sodiumoxide::crypto::secretbox;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::api::vault::VaultClient;
|
||||||
|
use crate::config::{Config, LocalProject};
|
||||||
|
use crate::crypto;
|
||||||
|
|
||||||
|
pub async fn run(force: bool, api_url: &str) -> Result<()> {
|
||||||
|
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||||
|
|
||||||
|
let config = Config::load()?;
|
||||||
|
let auth = config.require_auth()?;
|
||||||
|
let keypair = config.require_keypair()?;
|
||||||
|
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||||
|
let mut project = LocalProject::load()?;
|
||||||
|
|
||||||
|
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||||
|
|
||||||
|
let latest = match vc.get_latest_snapshot(&project.project_id).await? {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
println!("No snapshots found for this project yet.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !force && latest.version <= project.latest_version {
|
||||||
|
println!("Already up to date (v{}).", project.latest_version);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let env_path = std::env::current_dir()?.join(".env");
|
||||||
|
if !force && env_path.exists() {
|
||||||
|
let proceed = Confirm::new()
|
||||||
|
.with_prompt("This will overwrite your local .env. Continue?")
|
||||||
|
.default(false)
|
||||||
|
.interact()?;
|
||||||
|
if !proceed {
|
||||||
|
println!("Aborted.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive vault key
|
||||||
|
let passphrase = Password::new()
|
||||||
|
.with_prompt("Passphrase")
|
||||||
|
.interact()?;
|
||||||
|
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||||
|
let salt: [u8; 32] = salt_bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||||
|
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
let ciphertext = B64.decode(&latest.ciphertext)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||||
|
let nonce_bytes = B64.decode(&latest.nonce)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||||
|
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||||
|
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||||
|
|
||||||
|
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
|
||||||
|
let env_map: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
|
||||||
|
|
||||||
|
write_env_file(&env_path, &env_map)?;
|
||||||
|
|
||||||
|
project.latest_version = latest.version;
|
||||||
|
project.save()?;
|
||||||
|
|
||||||
|
println!("Pulled v{} → .env ({} keys)", latest.version, env_map.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_env_file(
|
||||||
|
path: &std::path::Path,
|
||||||
|
map: &BTreeMap<String, String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let content: String = map
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{k}={v}\n"))
|
||||||
|
.collect();
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use dialoguer::Password;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||||
|
use crate::config::{Config, LocalProject};
|
||||||
|
use crate::crypto;
|
||||||
|
|
||||||
|
pub async fn run(message: Option<String>, force: bool, api_url: &str) -> Result<()> {
|
||||||
|
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||||
|
|
||||||
|
let config = Config::load()?;
|
||||||
|
let auth = config.require_auth()?;
|
||||||
|
let keypair = config.require_keypair()?;
|
||||||
|
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||||
|
let mut project = LocalProject::load()?;
|
||||||
|
|
||||||
|
let env_path = std::env::current_dir()?.join(".env");
|
||||||
|
if !env_path.exists() {
|
||||||
|
anyhow::bail!("No .env file found in the current directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt passphrase and derive vault key
|
||||||
|
let passphrase = Password::new()
|
||||||
|
.with_prompt("Passphrase")
|
||||||
|
.interact()?;
|
||||||
|
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||||
|
let salt: [u8; 32] = salt_bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||||
|
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||||
|
|
||||||
|
// Parse .env → BTreeMap (deterministic ordering)
|
||||||
|
let env_map = parse_env_file(&env_path)?;
|
||||||
|
let plaintext = serde_json::to_vec(&env_map)?;
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
let (ciphertext, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||||
|
|
||||||
|
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||||
|
|
||||||
|
// Conflict check
|
||||||
|
if !force {
|
||||||
|
if let Some(latest) = vc.get_latest_snapshot(&project.project_id).await? {
|
||||||
|
if latest.version > project.latest_version {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Remote is at v{}, you have v{}. Run 'envsync pull' first or use --force.",
|
||||||
|
latest.version,
|
||||||
|
project.latest_version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let snap = vc
|
||||||
|
.push_snapshot(
|
||||||
|
&project.project_id,
|
||||||
|
PushSnapshotRequest {
|
||||||
|
ciphertext: B64.encode(&ciphertext),
|
||||||
|
nonce: B64.encode(nonce.0),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
project.latest_version = snap.version;
|
||||||
|
project.save()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Pushed v{} to '{}' ({})",
|
||||||
|
snap.version, project.project_name, project.environment
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_env_file(path: &Path) -> Result<BTreeMap<String, String>> {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
for item in dotenvy::from_path_iter(path)? {
|
||||||
|
let (k, v) = item?;
|
||||||
|
map.insert(k, v);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use dialoguer::{Confirm, Password};
|
||||||
|
use sodiumoxide::crypto::secretbox;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||||
|
use crate::config::{Config, LocalProject};
|
||||||
|
use crate::crypto;
|
||||||
|
|
||||||
|
pub async fn run(version: u32, yes: bool, api_url: &str) -> Result<()> {
|
||||||
|
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||||
|
|
||||||
|
let config = Config::load()?;
|
||||||
|
let auth = config.require_auth()?;
|
||||||
|
let keypair = config.require_keypair()?;
|
||||||
|
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||||
|
let mut project = LocalProject::load()?;
|
||||||
|
|
||||||
|
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||||
|
|
||||||
|
// Find the snapshot with the target version
|
||||||
|
let snapshots = vc.list_snapshots(&project.project_id, 1000).await?;
|
||||||
|
let target_meta = snapshots
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.version == version)
|
||||||
|
.ok_or_else(|| anyhow!("Snapshot v{version} not found."))?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Rolling back to v{} — {} — {}",
|
||||||
|
target_meta.version,
|
||||||
|
target_meta.created_at,
|
||||||
|
target_meta.message.as_deref().unwrap_or("-")
|
||||||
|
);
|
||||||
|
|
||||||
|
if !yes {
|
||||||
|
let confirmed = Confirm::new()
|
||||||
|
.with_prompt("This will push a new snapshot with the content from that version. Continue?")
|
||||||
|
.default(false)
|
||||||
|
.interact()?;
|
||||||
|
if !confirmed {
|
||||||
|
println!("Aborted.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full snapshot
|
||||||
|
let snap = vc.get_snapshot(&project.project_id, &target_meta.id).await?;
|
||||||
|
|
||||||
|
let passphrase = Password::new()
|
||||||
|
.with_prompt("Passphrase")
|
||||||
|
.interact()?;
|
||||||
|
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||||
|
let salt: [u8; 32] = salt_bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||||
|
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
let ciphertext = B64.decode(&snap.ciphertext)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||||
|
let nonce_bytes = B64.decode(&snap.nonce)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||||
|
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||||
|
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||||
|
let plaintext = crypto::decrypt_vault(&ciphertext, &nonce, &vault_key)?;
|
||||||
|
let env_map: BTreeMap<String, String> = serde_json::from_slice(&plaintext)?;
|
||||||
|
|
||||||
|
// Re-encrypt with a fresh nonce and push as new snapshot (preserves history)
|
||||||
|
let (new_ct, new_nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||||
|
let new_snap = vc
|
||||||
|
.push_snapshot(
|
||||||
|
&project.project_id,
|
||||||
|
PushSnapshotRequest {
|
||||||
|
ciphertext: B64.encode(&new_ct),
|
||||||
|
nonce: B64.encode(new_nonce.0),
|
||||||
|
message: Some(format!("Rollback to v{version}")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Write .env
|
||||||
|
let env_path = std::env::current_dir()?.join(".env");
|
||||||
|
crate::commands::pull::write_env_file(&env_path, &env_map)?;
|
||||||
|
|
||||||
|
project.latest_version = new_snap.version;
|
||||||
|
project.save()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Rolled back to v{version} content → pushed as v{} ({} keys)",
|
||||||
|
new_snap.version,
|
||||||
|
env_map.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use dialoguer::Password;
|
||||||
|
use sodiumoxide::crypto::secretbox;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||||
|
use crate::config::{Config, LocalProject};
|
||||||
|
use crate::crypto;
|
||||||
|
|
||||||
|
pub async fn run(key: String, value: String, message: Option<String>, api_url: &str) -> Result<()> {
|
||||||
|
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||||
|
|
||||||
|
let config = Config::load()?;
|
||||||
|
let auth = config.require_auth()?;
|
||||||
|
let keypair = config.require_keypair()?;
|
||||||
|
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||||
|
let mut project = LocalProject::load()?;
|
||||||
|
|
||||||
|
let passphrase = Password::new()
|
||||||
|
.with_prompt("Passphrase")
|
||||||
|
.interact()?;
|
||||||
|
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||||
|
let salt: [u8; 32] = salt_bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||||
|
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||||
|
|
||||||
|
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||||
|
|
||||||
|
// Pull current remote state (or start fresh)
|
||||||
|
let mut env_map: BTreeMap<String, String> =
|
||||||
|
match vc.get_latest_snapshot(&project.project_id).await? {
|
||||||
|
Some(snap) => {
|
||||||
|
let ct = B64.decode(&snap.ciphertext)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||||
|
let nonce_bytes = B64.decode(&snap.nonce)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||||
|
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||||
|
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||||
|
let plaintext = crypto::decrypt_vault(&ct, &nonce, &vault_key)?;
|
||||||
|
serde_json::from_slice(&plaintext)?
|
||||||
|
}
|
||||||
|
None => BTreeMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
env_map.insert(key.clone(), value.clone());
|
||||||
|
|
||||||
|
let plaintext = serde_json::to_vec(&env_map)?;
|
||||||
|
let (ct, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||||
|
|
||||||
|
let msg = message.unwrap_or_else(|| format!("Set {key}"));
|
||||||
|
let snap = vc
|
||||||
|
.push_snapshot(
|
||||||
|
&project.project_id,
|
||||||
|
PushSnapshotRequest {
|
||||||
|
ciphertext: B64.encode(&ct),
|
||||||
|
nonce: B64.encode(nonce.0),
|
||||||
|
message: Some(msg),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update local .env
|
||||||
|
let env_path = std::env::current_dir()?.join(".env");
|
||||||
|
crate::commands::pull::write_env_file(&env_path, &env_map)?;
|
||||||
|
|
||||||
|
project.latest_version = snap.version;
|
||||||
|
project.save()?;
|
||||||
|
|
||||||
|
println!("Set {key}={value} → pushed v{}", snap.version);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
use crate::config::{Config, LocalProject};
|
||||||
|
|
||||||
|
pub fn run() -> Result<()> {
|
||||||
|
let config = Config::load()?;
|
||||||
|
|
||||||
|
println!("EnvSync Status");
|
||||||
|
println!("{}", "─".repeat(40));
|
||||||
|
|
||||||
|
// Auth status
|
||||||
|
match &config.auth {
|
||||||
|
None => {
|
||||||
|
println!("Auth: not logged in");
|
||||||
|
println!(" Run 'envsync login' to authenticate.");
|
||||||
|
}
|
||||||
|
Some(auth) => {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let expires_in = auth.expires_at - now;
|
||||||
|
let expiry_str = if expires_in > 0 {
|
||||||
|
let dt = DateTime::<Utc>::from_timestamp(auth.expires_at, 0)
|
||||||
|
.map(|d| d.format("%H:%M:%S UTC").to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".into());
|
||||||
|
format!("expires at {dt} ({expires_in}s)")
|
||||||
|
} else {
|
||||||
|
"EXPIRED — run 'envsync login'".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Auth: logged in");
|
||||||
|
println!("User: {}", auth.user_email);
|
||||||
|
println!("Token: {expiry_str}");
|
||||||
|
println!("Refresh: {}", if auth.refresh_token.is_some() { "available" } else { "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", "─".repeat(40));
|
||||||
|
|
||||||
|
// Keypair status
|
||||||
|
match &config.keypair {
|
||||||
|
None => println!("Keypair: not generated"),
|
||||||
|
Some(kp) => println!("Keypair: {} (X25519)", &kp.public_key[..16]),
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", "─".repeat(40));
|
||||||
|
|
||||||
|
// Local project status
|
||||||
|
match LocalProject::load() {
|
||||||
|
Err(_) => println!("Project: no .envsync file (run 'envsync init')"),
|
||||||
|
Ok(proj) => {
|
||||||
|
println!("Project: {}", proj.project_name);
|
||||||
|
println!("Env: {}", proj.environment);
|
||||||
|
println!("Local v: v{}", proj.latest_version);
|
||||||
|
println!("ID: {}", proj.project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", "─".repeat(40));
|
||||||
|
println!("API: {}", config.api_url);
|
||||||
|
println!("Keycloak: {}", config.keycloak_url);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use dialoguer::Password;
|
||||||
|
use sodiumoxide::crypto::secretbox;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::api::vault::{PushSnapshotRequest, VaultClient};
|
||||||
|
use crate::config::{Config, LocalProject};
|
||||||
|
use crate::crypto;
|
||||||
|
|
||||||
|
pub async fn run(key: String, message: Option<String>, api_url: &str) -> Result<()> {
|
||||||
|
sodiumoxide::init().map_err(|_| anyhow!("Failed to initialize libsodium"))?;
|
||||||
|
|
||||||
|
let config = Config::load()?;
|
||||||
|
let auth = config.require_auth()?;
|
||||||
|
let keypair = config.require_keypair()?;
|
||||||
|
let effective_api_url = if !api_url.is_empty() { api_url } else { &config.api_url };
|
||||||
|
let mut project = LocalProject::load()?;
|
||||||
|
|
||||||
|
let passphrase = Password::new()
|
||||||
|
.with_prompt("Passphrase")
|
||||||
|
.interact()?;
|
||||||
|
let salt_bytes = B64.decode(&keypair.argon2_salt)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode salt: {e}"))?;
|
||||||
|
let salt: [u8; 32] = salt_bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("Invalid salt length"))?;
|
||||||
|
let vault_key = crypto::derive_key(&passphrase, &salt)?;
|
||||||
|
|
||||||
|
let vc = VaultClient::new(effective_api_url, &auth.access_token);
|
||||||
|
|
||||||
|
let mut env_map: BTreeMap<String, String> =
|
||||||
|
match vc.get_latest_snapshot(&project.project_id).await? {
|
||||||
|
Some(snap) => {
|
||||||
|
let ct = B64.decode(&snap.ciphertext)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode ciphertext: {e}"))?;
|
||||||
|
let nonce_bytes = B64.decode(&snap.nonce)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode nonce: {e}"))?;
|
||||||
|
let nonce = secretbox::Nonce::from_slice(&nonce_bytes)
|
||||||
|
.ok_or_else(|| anyhow!("Invalid nonce length"))?;
|
||||||
|
let plaintext = crypto::decrypt_vault(&ct, &nonce, &vault_key)?;
|
||||||
|
serde_json::from_slice(&plaintext)?
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
anyhow::bail!("No remote snapshot found; nothing to unset.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !env_map.contains_key(&key) {
|
||||||
|
anyhow::bail!("Key '{key}' not found in the vault.");
|
||||||
|
}
|
||||||
|
env_map.remove(&key);
|
||||||
|
|
||||||
|
let plaintext = serde_json::to_vec(&env_map)?;
|
||||||
|
let (ct, nonce) = crypto::encrypt_vault(&plaintext, &vault_key)?;
|
||||||
|
|
||||||
|
let msg = message.unwrap_or_else(|| format!("Unset {key}"));
|
||||||
|
let snap = vc
|
||||||
|
.push_snapshot(
|
||||||
|
&project.project_id,
|
||||||
|
PushSnapshotRequest {
|
||||||
|
ciphertext: B64.encode(&ct),
|
||||||
|
nonce: B64.encode(nonce.0),
|
||||||
|
message: Some(msg),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update local .env
|
||||||
|
let env_path = std::env::current_dir()?.join(".env");
|
||||||
|
crate::commands::pull::write_env_file(&env_path, &env_map)?;
|
||||||
|
|
||||||
|
project.latest_version = snap.version;
|
||||||
|
project.save()?;
|
||||||
|
|
||||||
|
println!("Unset {key} → pushed v{}", snap.version);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn default_api_url() -> String {
|
||||||
|
"http://localhost:8081".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_keycloak_url() -> String {
|
||||||
|
"http://localhost:8180/realms/envsync".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
/// Unix timestamp (seconds since epoch)
|
||||||
|
pub expires_at: i64,
|
||||||
|
pub user_id: String,
|
||||||
|
pub user_email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X25519 keypair — private key is encrypted with the Argon2id-derived master key.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct KeypairConfig {
|
||||||
|
/// base64 X25519 public key (32 bytes)
|
||||||
|
pub public_key: String,
|
||||||
|
/// base64 encrypted private key ciphertext
|
||||||
|
pub private_key_ct: String,
|
||||||
|
/// base64 nonce used to encrypt the private key (24 bytes)
|
||||||
|
pub privkey_nonce: String,
|
||||||
|
/// base64 Argon2id salt (32 bytes)
|
||||||
|
pub argon2_salt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_api_url")]
|
||||||
|
pub api_url: String,
|
||||||
|
#[serde(default = "default_keycloak_url")]
|
||||||
|
pub keycloak_url: String,
|
||||||
|
pub auth: Option<AuthConfig>,
|
||||||
|
pub keypair: Option<KeypairConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
api_url: default_api_url(),
|
||||||
|
keycloak_url: default_keycloak_url(),
|
||||||
|
auth: None,
|
||||||
|
keypair: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn path() -> PathBuf {
|
||||||
|
ProjectDirs::from("com", "envsync", "cli")
|
||||||
|
.expect("Failed to get config directory")
|
||||||
|
.config_dir()
|
||||||
|
.join("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::path();
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("Failed to read config from {path:?}"))?;
|
||||||
|
Ok(toml::from_str(&content)?)
|
||||||
|
} else {
|
||||||
|
Ok(Config::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = Self::path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
fs::write(&path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_auth(&self) -> Result<&AuthConfig> {
|
||||||
|
self.auth
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync login' first."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a valid AuthConfig, automatically refreshing the access token if it has
|
||||||
|
/// expired (or will expire within 60 seconds) and a refresh token is available.
|
||||||
|
pub async fn require_valid_auth(&mut self) -> Result<&AuthConfig> {
|
||||||
|
let auth = self
|
||||||
|
.auth
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync login' first."))?;
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
// Token still valid (with 60-second buffer)
|
||||||
|
if auth.expires_at > now + 60 {
|
||||||
|
return Ok(self.auth.as_ref().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt refresh if we have a refresh token
|
||||||
|
let refresh_tok = match auth.refresh_token.clone() {
|
||||||
|
Some(rt) if !rt.is_empty() => rt,
|
||||||
|
_ => {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Access token expired. Run 'envsync login' to re-authenticate."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let keycloak_url = self.keycloak_url.clone();
|
||||||
|
|
||||||
|
let new_tokens =
|
||||||
|
crate::api::auth::refresh_token(&client, &keycloak_url, "envsync-app-cli", &refresh_tok)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Session expired. Run 'envsync login' to re-authenticate. ({e})"))?;
|
||||||
|
|
||||||
|
// Decode new expiry from JWT
|
||||||
|
let exp = decode_jwt_exp(&new_tokens.access_token).unwrap_or(now + new_tokens.expires_in as i64);
|
||||||
|
|
||||||
|
let existing = self.auth.as_ref().unwrap();
|
||||||
|
self.auth = Some(AuthConfig {
|
||||||
|
access_token: new_tokens.access_token,
|
||||||
|
refresh_token: new_tokens.refresh_token.or_else(|| existing.refresh_token.clone()),
|
||||||
|
expires_at: exp,
|
||||||
|
user_id: existing.user_id.clone(),
|
||||||
|
user_email: existing.user_email.clone(),
|
||||||
|
});
|
||||||
|
self.save()?;
|
||||||
|
|
||||||
|
Ok(self.auth.as_ref().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_keypair(&self) -> Result<&KeypairConfig> {
|
||||||
|
self.keypair
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("No keypair found. Run 'envsync login' first."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_jwt_exp(token: &str) -> Option<i64> {
|
||||||
|
let parts: Vec<&str> = token.split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(parts[1])
|
||||||
|
.ok()?;
|
||||||
|
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||||
|
claims["exp"].as_i64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-repository state stored in `.envsync` (gitignored).
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||||
|
pub struct LocalProject {
|
||||||
|
pub project_id: String,
|
||||||
|
pub project_name: String,
|
||||||
|
pub environment: String,
|
||||||
|
pub latest_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalProject {
|
||||||
|
pub fn path() -> PathBuf {
|
||||||
|
std::env::current_dir()
|
||||||
|
.expect("Failed to get current directory")
|
||||||
|
.join(".envsync")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::path();
|
||||||
|
if !path.exists() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"No project found in current directory. Run 'envsync init' first."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
Ok(toml::from_str(&content)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = Self::path();
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
fs::write(&path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
|
use sodiumoxide::crypto::{box_, secretbox};
|
||||||
|
|
||||||
|
use crate::error::EnvSyncError;
|
||||||
|
|
||||||
|
/// Derive a 32-byte vault key from a passphrase using Argon2id.
|
||||||
|
pub fn derive_key(passphrase: &str, salt: &[u8; 32]) -> Result<[u8; 32], EnvSyncError> {
|
||||||
|
let params = Params::new(65536, 3, 1, Some(32))
|
||||||
|
.map_err(|e| EnvSyncError::CryptoError(e.to_string()))?;
|
||||||
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
argon2
|
||||||
|
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
|
||||||
|
.map_err(|e| EnvSyncError::CryptoError(e.to_string()))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random 32-byte Argon2id salt.
|
||||||
|
pub fn generate_salt() -> [u8; 32] {
|
||||||
|
let mut salt = [0u8; 32];
|
||||||
|
sodiumoxide::randombytes::randombytes_into(&mut salt);
|
||||||
|
salt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an X25519 keypair.
|
||||||
|
pub fn generate_keypair() -> (box_::PublicKey, box_::SecretKey) {
|
||||||
|
box_::gen_keypair()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt plaintext with XSalsa20-Poly1305. Returns (ciphertext, nonce).
|
||||||
|
pub fn encrypt_vault(
|
||||||
|
plaintext: &[u8],
|
||||||
|
key: &[u8; 32],
|
||||||
|
) -> Result<(Vec<u8>, secretbox::Nonce), EnvSyncError> {
|
||||||
|
let sk = secretbox::Key::from_slice(key)
|
||||||
|
.ok_or_else(|| EnvSyncError::CryptoError("Invalid vault key length".to_string()))?;
|
||||||
|
let nonce = secretbox::gen_nonce();
|
||||||
|
let ciphertext = secretbox::seal(plaintext, &nonce, &sk);
|
||||||
|
Ok((ciphertext, nonce))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt ciphertext produced by `encrypt_vault`.
|
||||||
|
pub fn decrypt_vault(
|
||||||
|
ciphertext: &[u8],
|
||||||
|
nonce: &secretbox::Nonce,
|
||||||
|
key: &[u8; 32],
|
||||||
|
) -> Result<Vec<u8>, EnvSyncError> {
|
||||||
|
let sk = secretbox::Key::from_slice(key)
|
||||||
|
.ok_or_else(|| EnvSyncError::CryptoError("Invalid vault key length".to_string()))?;
|
||||||
|
secretbox::open(ciphertext, nonce, &sk)
|
||||||
|
.map_err(|_| EnvSyncError::CryptoError("Decryption failed — wrong passphrase?".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt an X25519 private key with the master key.
|
||||||
|
pub fn encrypt_privkey(
|
||||||
|
privkey: &box_::SecretKey,
|
||||||
|
master_key: &[u8; 32],
|
||||||
|
) -> Result<(Vec<u8>, secretbox::Nonce), EnvSyncError> {
|
||||||
|
encrypt_vault(&privkey.0, master_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt an X25519 private key with the master key.
|
||||||
|
pub fn decrypt_privkey(
|
||||||
|
ct: &[u8],
|
||||||
|
nonce: &secretbox::Nonce,
|
||||||
|
master_key: &[u8; 32],
|
||||||
|
) -> Result<box_::SecretKey, EnvSyncError> {
|
||||||
|
let bytes = decrypt_vault(ct, nonce, master_key)?;
|
||||||
|
box_::SecretKey::from_slice(&bytes)
|
||||||
|
.ok_or_else(|| EnvSyncError::CryptoError("Invalid private key length".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_vault() {
|
||||||
|
sodiumoxide::init().unwrap();
|
||||||
|
let passphrase = "test-passphrase";
|
||||||
|
let salt = generate_salt();
|
||||||
|
let key = derive_key(passphrase, &salt).unwrap();
|
||||||
|
let plaintext = b"FOO=bar\nDB=postgres://localhost/test";
|
||||||
|
let (ct, nonce) = encrypt_vault(plaintext, &key).unwrap();
|
||||||
|
let decrypted = decrypt_vault(&ct, &nonce, &key).unwrap();
|
||||||
|
assert_eq!(decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_passphrase_fails() {
|
||||||
|
sodiumoxide::init().unwrap();
|
||||||
|
let salt = generate_salt();
|
||||||
|
let key = derive_key("correct", &salt).unwrap();
|
||||||
|
let (ct, nonce) = encrypt_vault(b"secret", &key).unwrap();
|
||||||
|
let wrong_key = derive_key("wrong", &salt).unwrap();
|
||||||
|
assert!(decrypt_vault(&ct, &nonce, &wrong_key).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_privkey() {
|
||||||
|
sodiumoxide::init().unwrap();
|
||||||
|
let salt = generate_salt();
|
||||||
|
let master_key = derive_key("passphrase", &salt).unwrap();
|
||||||
|
let (_, sec) = generate_keypair();
|
||||||
|
let (ct, nonce) = encrypt_privkey(&sec, &master_key).unwrap();
|
||||||
|
let recovered = decrypt_privkey(&ct, &nonce, &master_key).unwrap();
|
||||||
|
assert_eq!(sec.0, recovered.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum EnvSyncError {
|
||||||
|
#[error("Not authenticated. Run 'envsync login' first.")]
|
||||||
|
NotAuthenticated,
|
||||||
|
|
||||||
|
#[error("No project found. Run 'envsync init' in your project directory.")]
|
||||||
|
NoProject,
|
||||||
|
|
||||||
|
#[error("API error ({status}): {message}")]
|
||||||
|
ApiError { status: u16, message: String },
|
||||||
|
|
||||||
|
#[error("Crypto error: {0}")]
|
||||||
|
CryptoError(String),
|
||||||
|
|
||||||
|
#[error("Config error: {0}")]
|
||||||
|
ConfigError(String),
|
||||||
|
|
||||||
|
#[error("Push conflict: remote has newer version. Use --force to override.")]
|
||||||
|
PushConflict,
|
||||||
|
|
||||||
|
#[error("Snapshot version {0} not found.")]
|
||||||
|
SnapshotNotFound(u32),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Serde(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
}
|
||||||
+30
-19
@@ -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 token = authenticate(&client, &keycloak_url, &client_id).await?;
|
|
||||||
|
|
||||||
println!("Access Token: {}", token);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
|
|
||||||
|
let result = match cli.command {
|
||||||
|
Commands::Login => commands::login::run(api_url).await,
|
||||||
|
Commands::Logout => commands::logout::run(),
|
||||||
|
Commands::Init { name, env } => commands::init::run(name, env, api_url).await,
|
||||||
|
Commands::Push { message, force } => commands::push::run(message, force, api_url).await,
|
||||||
|
Commands::Pull { force } => commands::pull::run(force, api_url).await,
|
||||||
|
Commands::Diff { show_values } => commands::diff::run(show_values, api_url).await,
|
||||||
|
Commands::Log { limit } => commands::log::run(limit, api_url).await,
|
||||||
|
Commands::Set { key, value, message } => {
|
||||||
|
commands::set::run(key, value, message, api_url).await
|
||||||
|
}
|
||||||
|
Commands::Unset { key, message } => commands::unset::run(key, message, api_url).await,
|
||||||
|
Commands::Rollback { version, yes } => commands::rollback::run(version, yes, api_url).await,
|
||||||
|
Commands::Status => async { commands::status::run() }.await,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("Error: {e:#}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) redirect("/api/auth/signin/keycloak");
|
||||||
|
|
||||||
|
const username = session.preferredUsername ?? session.user?.email ?? "user";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-[#050505] text-white">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="sticky top-0 h-screen w-60 shrink-0 border-r border-white/5 bg-zinc-950 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-5 border-b border-white/5">
|
||||||
|
<Link href="/" className="flex items-center gap-2 text-base font-bold tracking-tight">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-emerald-500 text-xs font-black text-black">
|
||||||
|
E
|
||||||
|
</span>
|
||||||
|
EnvSync
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
|
||||||
|
<Link
|
||||||
|
href="/dashbaord"
|
||||||
|
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-zinc-400 hover:bg-white/[0.04] hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<GridIcon />
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/api/auth/signout"
|
||||||
|
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-zinc-400 hover:bg-white/[0.04] hover:text-white transition-colors mt-auto"
|
||||||
|
>
|
||||||
|
<LogoutIcon />
|
||||||
|
Sign out
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-white/5 px-4 py-3">
|
||||||
|
<p className="truncate text-xs text-zinc-500">{username}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GridIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogoutIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { createApiClient, type Project } from "@/lib/api";
|
||||||
|
|
||||||
|
const ENV_COLORS: Record<string, string> = {
|
||||||
|
production: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
|
staging: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||||
|
development: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
function EnvBadge({ env }: { env: string }) {
|
||||||
|
const cls = ENV_COLORS[env.toLowerCase()] ?? "bg-zinc-800 text-zinc-400 border-zinc-700";
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${cls}`}>
|
||||||
|
{env}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Project Modal ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ENVIRONMENTS = ["development", "staging", "production", "test"];
|
||||||
|
|
||||||
|
function CreateProjectModal({
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (name: string, env: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [env, setEnv] = useState("development");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onCreate(name.trim(), env);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create project");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-6 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold">New Project</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
Create a new encrypted vault for your team.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-300 mb-1.5">
|
||||||
|
Project name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="my-app"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-300 mb-1.5">
|
||||||
|
Environment
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={env}
|
||||||
|
onChange={(e) => setEnv(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-white outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50"
|
||||||
|
>
|
||||||
|
{ENVIRONMENTS.map((e) => (
|
||||||
|
<option key={e} value={e}>{e}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !name.trim()}
|
||||||
|
className="flex-1 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Creating…" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete Confirmation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DeleteConfirmModal({
|
||||||
|
project,
|
||||||
|
onClose,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onDelete();
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-red-500/20 bg-zinc-950 p-6 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-red-400">Delete project?</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
|
This will permanently delete{" "}
|
||||||
|
<span className="font-medium text-white">{project.name}</span> and all
|
||||||
|
its snapshots. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 rounded-lg bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-400 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project Card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProjectCard({
|
||||||
|
project,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
onDelete: (p: Project) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="group relative rounded-2xl border border-white/[0.06] bg-zinc-900/40 p-5 hover:border-emerald-500/20 hover:bg-emerald-500/[0.03] transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/dashbaord/projects/${project.id}`}
|
||||||
|
className="block truncate font-semibold tracking-tight hover:text-emerald-400 transition-colors"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
Created {formatDate(project.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<EnvBadge env={project.environment} />
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(project)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 rounded-lg p-1.5 text-zinc-600 hover:text-red-400 hover:bg-red-500/10 transition-all"
|
||||||
|
title="Delete project"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/dashbaord/projects/${project.id}`}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.06] px-3 py-1.5 text-xs text-zinc-400 hover:text-white hover:border-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<HistoryIcon />
|
||||||
|
View snapshots
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrashIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||||
|
|
||||||
|
const fetchProjects = useCallback(async () => {
|
||||||
|
if (!session?.accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const api = createApiClient(session.accessToken);
|
||||||
|
const list = await api.listProjects();
|
||||||
|
setProjects(list);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load projects");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [session?.accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "authenticated") {
|
||||||
|
fetchProjects();
|
||||||
|
}
|
||||||
|
}, [status, fetchProjects]);
|
||||||
|
|
||||||
|
async function handleCreate(name: string, env: string) {
|
||||||
|
if (!session?.accessToken) return;
|
||||||
|
const api = createApiClient(session.accessToken);
|
||||||
|
await api.createProject(name, env);
|
||||||
|
await fetchProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!session?.accessToken || !deleteTarget) return;
|
||||||
|
const api = createApiClient(session.accessToken);
|
||||||
|
await api.deleteProject(deleteTarget.id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await fetchProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "loading" || loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-20">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{projects.length} project{projects.length !== 1 ? "s" : ""} in your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400 transition-colors"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
New project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CLI hint */}
|
||||||
|
<div className="mt-6 rounded-xl border border-white/[0.06] bg-zinc-900/40 px-4 py-3 font-mono text-xs text-zinc-500">
|
||||||
|
<span className="text-emerald-400">$</span>{" "}
|
||||||
|
envsync init --project <name> --env <env>
|
||||||
|
<span className="ml-4 not-italic font-sans text-zinc-600">← or create via this dashboard</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-6 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
<button onClick={fetchProjects} className="ml-3 underline hover:no-underline">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Projects grid */}
|
||||||
|
{projects.length === 0 && !error ? (
|
||||||
|
<div className="mt-16 flex flex-col items-center text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/[0.06] bg-zinc-900/60">
|
||||||
|
<FolderIcon />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 font-semibold">No projects yet</h3>
|
||||||
|
<p className="mt-2 max-w-sm text-sm text-zinc-500">
|
||||||
|
Create your first project to start syncing encrypted environment variables across your team.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-black hover:bg-emerald-400 transition-colors"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Create first project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{projects.map((p) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={p.id}
|
||||||
|
project={p}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showCreate && (
|
||||||
|
<CreateProjectModal
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
project={deleteTarget}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlusIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-8 w-8 text-zinc-600" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25m19.5 0A2.25 2.25 0 0 1 19.5 16.5h-15a2.25 2.25 0 0 1-2.25-2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 16.91a2.25 2.25 0 0 1-1.07-1.916V14.25" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { createApiClient, type Project, type SnapshotMeta } from "@/lib/api";
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(iso: string) {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60_000);
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENV_BADGE: Record<string, string> = {
|
||||||
|
production: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
|
staging: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||||
|
development: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Snapshot Row ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SnapshotRow({
|
||||||
|
snap,
|
||||||
|
isLatest,
|
||||||
|
}: {
|
||||||
|
snap: SnapshotMeta;
|
||||||
|
isLatest: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-4 py-4 first:pt-0">
|
||||||
|
{/* Version bubble */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-bold ${
|
||||||
|
isLatest
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
|
||||||
|
: "border-white/10 bg-zinc-800 text-zinc-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
v{snap.version}
|
||||||
|
</div>
|
||||||
|
{/* connector line — handled by parent spacing */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{isLatest && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-400">
|
||||||
|
latest
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{snap.message ?? <span className="italic text-zinc-500">No message</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
{snap.pushed_by ? (
|
||||||
|
<>
|
||||||
|
<span className="font-mono text-zinc-400">{snap.pushed_by.split("-")[0]}…</span>
|
||||||
|
{" · "}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<span title={formatDate(snap.created_at)}>{formatRelative(snap.created_at)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 text-xs text-zinc-600" title={formatDate(snap.created_at)}>
|
||||||
|
{formatDate(snap.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI Reference Panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CliPanel({ projectId }: { projectId: string }) {
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function copy(text: string, key: string) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(key);
|
||||||
|
setTimeout(() => setCopied(null), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = [
|
||||||
|
{ key: "push", cmd: "envsync push -m \"describe changes\"", desc: "Encrypt & push local .env" },
|
||||||
|
{ key: "pull", cmd: "envsync pull", desc: "Pull latest snapshot" },
|
||||||
|
{ key: "diff", cmd: "envsync diff", desc: "Compare local vs remote" },
|
||||||
|
{ key: "log", cmd: "envsync log", desc: "View snapshot history" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-zinc-900/40 p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-300">CLI Quick Reference</h2>
|
||||||
|
<p className="mt-1 text-xs text-zinc-600">
|
||||||
|
Project ID: <span className="font-mono text-zinc-500">{projectId}</span>
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{commands.map(({ key, cmd, desc }) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="group flex items-center justify-between gap-3 rounded-lg border border-white/[0.04] bg-zinc-900/60 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<code className="text-xs text-emerald-400">{cmd}</code>
|
||||||
|
<p className="text-xs text-zinc-600 mt-0.5">{desc}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => copy(cmd, key)}
|
||||||
|
className="shrink-0 rounded p-1 text-zinc-600 opacity-0 group-hover:opacity-100 hover:text-zinc-300 hover:bg-white/5 transition-all"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
{copied === key ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ProjectDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [snapshots, setSnapshots] = useState<SnapshotMeta[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!session?.accessToken || !id) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const api = createApiClient(session.accessToken);
|
||||||
|
const [proj, snaps] = await Promise.all([
|
||||||
|
api.getProject(id),
|
||||||
|
api.listSnapshots(id, 50),
|
||||||
|
]);
|
||||||
|
setProject(proj);
|
||||||
|
setSnapshots(snaps);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load project");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [session?.accessToken, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "authenticated") fetchData();
|
||||||
|
}, [status, fetchData]);
|
||||||
|
|
||||||
|
if (status === "loading" || loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-20">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
<button onClick={fetchData} className="ml-3 underline hover:no-underline">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
const envClass = ENV_BADGE[project.environment.toLowerCase()] ?? "bg-zinc-800 text-zinc-400 border-zinc-700";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl mx-auto">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-500 mb-6">
|
||||||
|
<Link href="/dashbaord" className="hover:text-white transition-colors">
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project header */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{project.name}</h1>
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${envClass}`}>
|
||||||
|
{project.environment}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
{snapshots.length} snapshot{snapshots.length !== 1 ? "s" : ""} · Created {formatDate(project.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Snapshot history */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-zinc-900/40">
|
||||||
|
<div className="border-b border-white/[0.06] px-5 py-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-300">Snapshot History</h2>
|
||||||
|
<span className="text-xs text-zinc-600">{snapshots.length} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{snapshots.length === 0 ? (
|
||||||
|
<div className="px-5 py-12 text-center">
|
||||||
|
<p className="text-sm text-zinc-500">No snapshots yet.</p>
|
||||||
|
<p className="mt-2 text-xs text-zinc-600">
|
||||||
|
Run{" "}
|
||||||
|
<code className="text-emerald-400">envsync push</code>{" "}
|
||||||
|
to create the first one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/[0.04] px-5">
|
||||||
|
{snapshots.map((snap, i) => (
|
||||||
|
<SnapshotRow
|
||||||
|
key={snap.id}
|
||||||
|
snap={snap}
|
||||||
|
isLatest={i === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rollback note */}
|
||||||
|
{snapshots.length > 1 && (
|
||||||
|
<div className="mt-3 flex items-start gap-2 rounded-xl border border-white/[0.04] bg-zinc-900/20 px-4 py-3">
|
||||||
|
<InfoIcon />
|
||||||
|
<p className="text-xs text-zinc-600">
|
||||||
|
To roll back:{" "}
|
||||||
|
<code className="text-emerald-500">envsync rollback --version <N></code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CliPanel projectId={id} />
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-zinc-900/40 p-5 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-300">Stats</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Stat label="Total snapshots" value={snapshots.length.toString()} />
|
||||||
|
<Stat
|
||||||
|
label="Latest version"
|
||||||
|
value={snapshots[0] ? `v${snapshots[0].version}` : "—"}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Last push"
|
||||||
|
value={snapshots[0] ? formatRelative(snapshots[0].created_at) : "—"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-zinc-500">{label}</span>
|
||||||
|
<span className="text-xs font-medium text-zinc-300">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-3.5 w-3.5 text-emerald-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-4 w-4 shrink-0 text-zinc-600" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8081";
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
environment: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotMeta {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
message: string | null;
|
||||||
|
pushed_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotFull extends SnapshotMeta {
|
||||||
|
ciphertext: string;
|
||||||
|
nonce: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
status: number;
|
||||||
|
error: string;
|
||||||
|
fields?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private token: string;
|
||||||
|
|
||||||
|
constructor(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let body: ApiError;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = { status: res.status, error: res.statusText };
|
||||||
|
}
|
||||||
|
const err = new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
|
(err as Error & { apiError: ApiError }).apiError = body;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as unknown as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projects ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listProjects(): Promise<Project[]> {
|
||||||
|
return this.request<Project[]>("/api/projects");
|
||||||
|
}
|
||||||
|
|
||||||
|
getProject(id: string): Promise<Project> {
|
||||||
|
return this.request<Project>(`/api/projects/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createProject(name: string, environment: string): Promise<Project> {
|
||||||
|
return this.request<Project>("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, environment }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProject(id: string): Promise<void> {
|
||||||
|
return this.request<void>(`/api/projects/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Snapshots ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listSnapshots(projectId: string, limit = 50): Promise<SnapshotMeta[]> {
|
||||||
|
return this.request<SnapshotMeta[]>(
|
||||||
|
`/api/projects/${projectId}/snapshots?limit=${limit}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatestSnapshot(projectId: string): Promise<SnapshotFull | null> {
|
||||||
|
return this.request<SnapshotFull>(
|
||||||
|
`/api/projects/${projectId}/snapshots/latest`
|
||||||
|
).catch((err) => {
|
||||||
|
if ((err as Error & { apiError?: ApiError }).apiError?.status === 404)
|
||||||
|
return null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(projectId: string, snapId: string): Promise<SnapshotFull> {
|
||||||
|
return this.request<SnapshotFull>(
|
||||||
|
`/api/projects/${projectId}/snapshots/${snapId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiClient(token: string): ApiClient {
|
||||||
|
return new ApiClient(token);
|
||||||
|
}
|
||||||
@@ -1,14 +1,44 @@
|
|||||||
import type { NextAuthOptions } from "next-auth";
|
import type { 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;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+4
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user