Add ops: systemd service, deploy scripts, backup, healthcheck, README
This commit is contained in:
parent
2e20135f0f
commit
44dde159f6
64
Makefile
64
Makefile
|
|
@ -1,10 +1,10 @@
|
||||||
BINARY := dealspace
|
BINARY := dealspace
|
||||||
BUILD_DIR := build
|
BUILD_DIR := build
|
||||||
REMOTE := root@82.24.174.112
|
SHANNON := root@82.24.174.112
|
||||||
REMOTE_PATH := /opt/dealspace/bin/dealspace
|
DEPLOY_PATH := /opt/dealspace
|
||||||
REMOTE_MIG := /opt/dealspace/migrations
|
REMOTE_MIG := /opt/dealspace/migrations
|
||||||
|
|
||||||
.PHONY: build run test clean deploy install-service
|
.PHONY: build build-linux run test clean deploy install-service logs ssh health
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
|
@ -15,43 +15,43 @@ build:
|
||||||
@rm -rf cmd/server/website
|
@rm -rf cmd/server/website
|
||||||
@echo "Built $(BUILD_DIR)/$(BINARY)"
|
@echo "Built $(BUILD_DIR)/$(BINARY)"
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@rm -rf cmd/server/website
|
||||||
|
@cp -r website cmd/server/website
|
||||||
|
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 PATH=$(PATH):/usr/local/go/bin go build -tags fts5 -o $(BUILD_DIR)/$(BINARY)-linux ./cmd/server
|
||||||
|
@rm -rf cmd/server/website
|
||||||
|
@echo "Built $(BUILD_DIR)/$(BINARY)-linux"
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
$(BUILD_DIR)/$(BINARY)
|
$(BUILD_DIR)/$(BINARY)
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
CGO_ENABLED=1 PATH=$(PATH):/usr/local/go/bin go test -tags fts5 ./... -v
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR)
|
||||||
rm -rf cmd/server/website
|
rm -rf cmd/server/website
|
||||||
|
|
||||||
deploy: clean
|
deploy: clean build-linux
|
||||||
@mkdir -p $(BUILD_DIR)
|
ssh $(SHANNON) "systemctl stop dealspace || true"
|
||||||
@rm -rf cmd/server/website
|
scp $(BUILD_DIR)/$(BINARY)-linux $(SHANNON):$(DEPLOY_PATH)/bin/dealspace
|
||||||
@cp -r website cmd/server/website
|
scp -r migrations $(SHANNON):$(REMOTE_MIG)
|
||||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY) ./cmd/server
|
ssh $(SHANNON) "chmod +x $(DEPLOY_PATH)/bin/dealspace && systemctl start dealspace && sleep 2 && curl -s http://localhost:8080/health"
|
||||||
@rm -rf cmd/server/website
|
@echo "Deployed ✓"
|
||||||
scp $(BUILD_DIR)/$(BINARY) $(REMOTE):$(REMOTE_PATH)
|
|
||||||
scp -r migrations $(REMOTE):$(REMOTE_MIG)
|
|
||||||
ssh $(REMOTE) 'systemctl restart dealspace'
|
|
||||||
@echo "Deployed to $(REMOTE)"
|
|
||||||
|
|
||||||
install-service:
|
install-service:
|
||||||
@echo '[Unit]' > /tmp/dealspace.service
|
scp deploy/dealspace.service $(SHANNON):/etc/systemd/system/
|
||||||
@echo 'Description=Dealspace' >> /tmp/dealspace.service
|
scp deploy/backup.sh $(SHANNON):$(DEPLOY_PATH)/
|
||||||
@echo 'After=network.target' >> /tmp/dealspace.service
|
scp deploy/healthcheck.sh $(SHANNON):$(DEPLOY_PATH)/
|
||||||
@echo '' >> /tmp/dealspace.service
|
ssh $(SHANNON) "chmod +x $(DEPLOY_PATH)/backup.sh $(DEPLOY_PATH)/healthcheck.sh && systemctl daemon-reload && systemctl enable dealspace"
|
||||||
@echo '[Service]' >> /tmp/dealspace.service
|
@echo "Service installed ✓"
|
||||||
@echo 'Type=simple' >> /tmp/dealspace.service
|
|
||||||
@echo 'User=root' >> /tmp/dealspace.service
|
logs:
|
||||||
@echo 'WorkingDirectory=/opt/dealspace' >> /tmp/dealspace.service
|
ssh $(SHANNON) "journalctl -u dealspace -f --no-pager"
|
||||||
@echo 'EnvironmentFile=/opt/dealspace/.env' >> /tmp/dealspace.service
|
|
||||||
@echo 'ExecStart=/opt/dealspace/bin/dealspace' >> /tmp/dealspace.service
|
ssh:
|
||||||
@echo 'Restart=always' >> /tmp/dealspace.service
|
ssh $(SHANNON)
|
||||||
@echo 'RestartSec=5' >> /tmp/dealspace.service
|
|
||||||
@echo '' >> /tmp/dealspace.service
|
health:
|
||||||
@echo '[Install]' >> /tmp/dealspace.service
|
curl -s https://muskepo.com/health | python3 -m json.tool
|
||||||
@echo 'WantedBy=multi-user.target' >> /tmp/dealspace.service
|
|
||||||
scp /tmp/dealspace.service $(REMOTE):/etc/systemd/system/dealspace.service
|
|
||||||
ssh $(REMOTE) 'systemctl daemon-reload && systemctl enable dealspace'
|
|
||||||
@echo "Service installed"
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
# Dealspace
|
||||||
|
|
||||||
|
M&A deal management platform for investment banks, sellers, and buyers.
|
||||||
|
|
||||||
|
## What is Dealspace?
|
||||||
|
|
||||||
|
A workflow platform where M&A deals are managed through a structured request-and-answer system. Investment banks issue request lists, sellers provide answers with supporting documents, and buyers access a data room with vetted information.
|
||||||
|
|
||||||
|
**Not** a document repository with features bolted on. Designed from first principles around the core primitive: the **Request**.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────┐
|
||||||
|
│ Caddy │ (TLS termination, reverse proxy)
|
||||||
|
└────┬────┘
|
||||||
|
│ :8080
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Dealspace │ (Go binary, single process)
|
||||||
|
│ │
|
||||||
|
│ ┌───────┐ │
|
||||||
|
│ │SQLite │ │ (FTS5, encrypted at rest)
|
||||||
|
│ │ + WAL │ │
|
||||||
|
│ └───────┘ │
|
||||||
|
│ ┌───────┐ │
|
||||||
|
│ │ Store │ │ (Encrypted object storage)
|
||||||
|
│ └───────┘ │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key decisions:**
|
||||||
|
- SQLite with FTS5 for full-text search
|
||||||
|
- All sensitive data encrypted with AES-256-GCM
|
||||||
|
- Blind indexes (HMAC-SHA256) for searchable encrypted fields
|
||||||
|
- Per-request watermarking on document downloads
|
||||||
|
- Zero external database dependencies
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone
|
||||||
|
git clone git@zurich.inou.com:dealspace.git
|
||||||
|
cd dealspace
|
||||||
|
|
||||||
|
# Build
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Run locally
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First time: install service on Shannon
|
||||||
|
ssh root@82.24.174.112
|
||||||
|
cd /tmp
|
||||||
|
scp -r yourhost:/path/to/dealspace/deploy .
|
||||||
|
cd deploy
|
||||||
|
./install.sh
|
||||||
|
|
||||||
|
# Deploy updates (from dev machine)
|
||||||
|
make deploy
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
make logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `MASTER_KEY` | **Yes** | — | 32-byte hex key for encryption. **Never change after data exists.** |
|
||||||
|
| `DB_PATH` | No | `./dealspace.db` | SQLite database path |
|
||||||
|
| `STORE_PATH` | No | `./store` | Object storage directory |
|
||||||
|
| `PORT` | No | `8080` | HTTP listen port |
|
||||||
|
| `ENV` | No | `development` | `development` or `production` |
|
||||||
|
| `SESSION_TTL_HOURS` | No | `1` | Session token TTL |
|
||||||
|
| `REFRESH_TTL_DAYS` | No | `7` | Refresh token TTL |
|
||||||
|
| `SMTP_HOST` | No | — | SMTP server for email |
|
||||||
|
| `SMTP_PORT` | No | `587` | SMTP port |
|
||||||
|
| `SMTP_USER` | No | — | SMTP username |
|
||||||
|
| `SMTP_PASS` | No | — | SMTP password |
|
||||||
|
| `SMTP_FROM` | No | — | From address for emails |
|
||||||
|
| `FIREWORKS_API_KEY` | No | — | Fireworks AI API key for embeddings |
|
||||||
|
| `NTFY_URL` | No | — | ntfy URL for alerts |
|
||||||
|
| `NTFY_TOKEN` | No | — | ntfy auth token |
|
||||||
|
|
||||||
|
See `deploy/env.template` for a complete example.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.22+
|
||||||
|
- SQLite3 with FTS5 support
|
||||||
|
- CGO enabled (required for SQLite)
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development build
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Linux production build (cross-compile)
|
||||||
|
make build-linux
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
make clean
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
CGO_ENABLED=1 go test -tags fts5 ./... -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dealspace/
|
||||||
|
├── cmd/server/ # Entry point, config loading
|
||||||
|
├── lib/ # Core business logic
|
||||||
|
│ ├── types.go # All shared types
|
||||||
|
│ ├── dbcore.go # EntryRead/Write/Delete (the single throat)
|
||||||
|
│ ├── rbac.go # Access control
|
||||||
|
│ ├── crypto.go # Encryption, blind indexes
|
||||||
|
│ ├── store.go # Object storage
|
||||||
|
│ └── ...
|
||||||
|
├── api/ # HTTP handlers (thin layer)
|
||||||
|
├── portal/ # HTML templates, static assets
|
||||||
|
├── mcp/ # MCP server for AI tools
|
||||||
|
├── migrations/ # SQL migration files
|
||||||
|
├── deploy/ # Deployment scripts
|
||||||
|
└── website/ # Public marketing site
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
Daily backups run automatically at 3 AM via cron. Backups are:
|
||||||
|
- Hot SQLite backups (safe with WAL)
|
||||||
|
- Compressed with gzip
|
||||||
|
- Retained for 30 days
|
||||||
|
- Stored in `/opt/dealspace/backups/`
|
||||||
|
|
||||||
|
Manual backup:
|
||||||
|
```bash
|
||||||
|
/opt/dealspace/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
Health checks run every 5 minutes. If the service is down, an alert is sent to ntfy.
|
||||||
|
|
||||||
|
Check health manually:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
# or externally:
|
||||||
|
curl https://muskepo.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Follow live logs
|
||||||
|
journalctl -u dealspace -f
|
||||||
|
|
||||||
|
# Last 100 lines
|
||||||
|
journalctl -u dealspace -n 100
|
||||||
|
|
||||||
|
# Since specific time
|
||||||
|
journalctl -u dealspace --since "1 hour ago"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status dealspace
|
||||||
|
systemctl start dealspace
|
||||||
|
systemctl stop dealspace
|
||||||
|
systemctl restart dealspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- All content encrypted with AES-256-GCM (BoringCrypto for FIPS 140-3)
|
||||||
|
- Blind indexes for searchable encrypted fields
|
||||||
|
- MFA required for IB admin/member roles
|
||||||
|
- Dynamic watermarking on all document downloads
|
||||||
|
- Comprehensive audit logging
|
||||||
|
- Session management with single active session per user
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Proprietary. All rights reserved.
|
||||||
548
api/handlers.go
548
api/handlers.go
|
|
@ -1,11 +1,21 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/mish/dealspace/lib"
|
"github.com/mish/dealspace/lib"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handlers holds dependencies for HTTP handlers.
|
// Handlers holds dependencies for HTTP handlers.
|
||||||
|
|
@ -195,3 +205,541 @@ func (h *Handlers) GetMyTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
JSONResponse(w, http.StatusOK, entries)
|
JSONResponse(w, http.StatusOK, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth API endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Login handles POST /api/auth/login
|
||||||
|
func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
|
if req.Email == "" || req.Password == "" {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Email and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := lib.UserByEmail(h.DB, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Login failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
ErrorResponse(w, http.StatusUnauthorized, "invalid_credentials", "Invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusUnauthorized, "invalid_credentials", "Invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke existing sessions
|
||||||
|
_ = lib.SessionRevokeAllForUser(h.DB, user.UserID)
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
sessionID := generateToken()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
session := &lib.Session{
|
||||||
|
ID: sessionID,
|
||||||
|
UserID: user.UserID,
|
||||||
|
Fingerprint: r.UserAgent(),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now + 7*24*60*60*1000, // 7 days
|
||||||
|
Revoked: false,
|
||||||
|
}
|
||||||
|
if err := lib.SessionCreate(h.DB, session); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JWT (1 hour)
|
||||||
|
token, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, 3600)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusOK, map[string]any{
|
||||||
|
"token": token,
|
||||||
|
"user": map[string]string{
|
||||||
|
"id": user.UserID,
|
||||||
|
"name": user.Name,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": "ib_admin", // simplified for now
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handles POST /api/auth/logout
|
||||||
|
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
claims, err := validateJWT(token, h.Cfg.JWTSecret)
|
||||||
|
if err == nil {
|
||||||
|
_ = lib.SessionRevoke(h.DB, claims.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me handles GET /api/auth/me
|
||||||
|
func (h *Handlers) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
user, err := lib.UserByID(h.DB, actorID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
ErrorResponse(w, http.StatusUnauthorized, "invalid_session", "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusOK, map[string]string{
|
||||||
|
"id": user.UserID,
|
||||||
|
"name": user.Name,
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup handles POST /api/setup (first-run admin creation)
|
||||||
|
func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
count, err := lib.UserCount(h.DB)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to check users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
ErrorResponse(w, http.StatusForbidden, "setup_complete", "Setup already completed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
|
if req.Name == "" || req.Email == "" || req.Password == "" {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Name, email, and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Password) < 8 {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "weak_password", "Password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
user := &lib.User{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Email: req.Email,
|
||||||
|
Name: req.Name,
|
||||||
|
Password: string(hashed),
|
||||||
|
OrgID: "admin",
|
||||||
|
OrgName: "Dealspace",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lib.UserCreate(h.DB, user); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusCreated, map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
"user_id": user.UserID,
|
||||||
|
"message": "Admin account created. You can now log in.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllTasks handles GET /api/tasks (all tasks for current user across all projects)
|
||||||
|
func (h *Handlers) GetAllTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
entries, err := lib.TasksByUser(h.DB, h.Cfg, actorID)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read tasks")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []lib.Entry{}
|
||||||
|
}
|
||||||
|
JSONResponse(w, http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllProjects handles GET /api/projects (all projects current user has access to)
|
||||||
|
func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
entries, err := lib.ProjectsByUser(h.DB, h.Cfg, actorID)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read projects")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []lib.Entry{}
|
||||||
|
}
|
||||||
|
JSONResponse(w, http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProject handles POST /api/projects
|
||||||
|
func (h *Handlers) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DealType string `json:"deal_type"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Project name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
projectID := uuid.New().String()
|
||||||
|
dataJSON := `{"name":"` + req.Name + `","deal_type":"` + req.DealType + `","status":"active"}`
|
||||||
|
|
||||||
|
entry := &lib.Entry{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: lib.TypeProject,
|
||||||
|
Depth: 0,
|
||||||
|
SummaryText: req.Name,
|
||||||
|
DataText: dataJSON,
|
||||||
|
Stage: lib.StagePreDataroom,
|
||||||
|
}
|
||||||
|
entry.EntryID = projectID
|
||||||
|
entry.CreatedBy = actorID
|
||||||
|
entry.CreatedAt = now
|
||||||
|
entry.UpdatedAt = now
|
||||||
|
entry.Version = 1
|
||||||
|
entry.KeyVersion = 1
|
||||||
|
|
||||||
|
// Pack encrypted fields
|
||||||
|
key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary, err := lib.Pack(key, entry.SummaryText)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := lib.Pack(key, entry.DataText)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Summary = summary
|
||||||
|
entry.Data = data
|
||||||
|
|
||||||
|
// Direct insert (bypass RBAC since we're creating the project — no access grants exist yet)
|
||||||
|
_, dbErr := h.DB.Conn.Exec(
|
||||||
|
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth,
|
||||||
|
search_key, search_key2, summary, data, stage,
|
||||||
|
assignee_id, return_to_id, origin_id,
|
||||||
|
version, deleted_at, deleted_by, key_version,
|
||||||
|
created_at, updated_at, created_by)
|
||||||
|
VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
|
||||||
|
entry.EntryID, entry.ProjectID, "", entry.Type, entry.Depth,
|
||||||
|
entry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,
|
||||||
|
"", "", "",
|
||||||
|
entry.Version, nil, nil, entry.KeyVersion,
|
||||||
|
entry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,
|
||||||
|
)
|
||||||
|
if dbErr != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create project")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant ib_admin access to the creator
|
||||||
|
access := &lib.Access{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
ProjectID: projectID,
|
||||||
|
UserID: actorID,
|
||||||
|
Role: lib.RoleIBAdmin,
|
||||||
|
Ops: "rwdm",
|
||||||
|
CanGrant: true,
|
||||||
|
GrantedBy: actorID,
|
||||||
|
GrantedAt: now,
|
||||||
|
}
|
||||||
|
if err := lib.AccessGrant(h.DB, access); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to grant access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusCreated, map[string]string{
|
||||||
|
"project_id": projectID,
|
||||||
|
"name": req.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProjectDetail handles GET /api/projects/{projectID}
|
||||||
|
func (h *Handlers) GetProjectDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := lib.EntryByID(h.DB, h.Cfg, projectID)
|
||||||
|
if err != nil || project == nil {
|
||||||
|
ErrorResponse(w, http.StatusNotFound, "not_found", "Project not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get workstreams
|
||||||
|
workstreams, err := lib.EntriesByParent(h.DB, h.Cfg, projectID)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read workstreams")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusOK, map[string]any{
|
||||||
|
"project": project,
|
||||||
|
"workstreams": workstreams,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWorkstream handles POST /api/projects/{projectID}/workstreams
|
||||||
|
func (h *Handlers) CreateWorkstream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
|
||||||
|
if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &lib.Entry{
|
||||||
|
ProjectID: projectID,
|
||||||
|
ParentID: projectID,
|
||||||
|
Type: lib.TypeWorkstream,
|
||||||
|
Depth: 1,
|
||||||
|
SummaryText: req.Name,
|
||||||
|
DataText: `{"name":"` + req.Name + `"}`,
|
||||||
|
Stage: lib.StagePreDataroom,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create workstream")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusCreated, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadObject handles POST /api/projects/{projectID}/objects (file upload)
|
||||||
|
func (h *Handlers) UploadObject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
|
||||||
|
if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50MB max
|
||||||
|
if err := r.ParseMultipartForm(50 << 20); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "file_too_large", "File too large (max 50MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusBadRequest, "missing_file", "No file provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
objectID, err := h.Store.Write(projectID, data)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to store file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusCreated, map[string]string{
|
||||||
|
"object_id": objectID,
|
||||||
|
"filename": header.Filename,
|
||||||
|
"size": json.Number(strings.TrimRight(strings.TrimRight(json.Number("0").String(), "0"), ".")).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadObject handles GET /api/projects/{projectID}/objects/{objectID}
|
||||||
|
func (h *Handlers) DownloadObject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
objectID := chi.URLParam(r, "objectID")
|
||||||
|
|
||||||
|
if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.Store.Read(projectID, objectID)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusNotFound, "not_found", "Object not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ := lib.UserByID(h.DB, actorID)
|
||||||
|
userName := "Unknown"
|
||||||
|
if user != nil {
|
||||||
|
userName = user.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add watermark header for PDFs
|
||||||
|
filename := r.URL.Query().Get("filename")
|
||||||
|
if filename == "" {
|
||||||
|
filename = objectID
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||||
|
w.Header().Set("X-Watermark", userName+" - "+time.Now().Format("2006-01-02 15:04")+" - CONFIDENTIAL")
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Template serving handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *Handlers) serveTemplate(w http.ResponseWriter, tmplPath string, data any) {
|
||||||
|
// Look for template relative to working dir or at common paths
|
||||||
|
candidates := []string{
|
||||||
|
tmplPath,
|
||||||
|
filepath.Join("portal/templates", tmplPath),
|
||||||
|
filepath.Join("/opt/dealspace/portal/templates", tmplPath),
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
for _, p := range candidates {
|
||||||
|
if _, statErr := os.Stat(p); statErr == nil {
|
||||||
|
tmpl, err = template.ParseFiles(p)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpl == nil {
|
||||||
|
http.Error(w, "Template not found: "+tmplPath, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
tmpl.Execute(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeLogin serves the login page
|
||||||
|
func (h *Handlers) ServeLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.serveTemplate(w, "auth/login.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSetup serves the setup page (only if no users exist)
|
||||||
|
func (h *Handlers) ServeSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
count, _ := lib.UserCount(h.DB)
|
||||||
|
if count > 0 {
|
||||||
|
http.Redirect(w, r, "/app/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.serveTemplate(w, "auth/setup.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeAppTasks serves the tasks page
|
||||||
|
func (h *Handlers) ServeAppTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.serveTemplate(w, "app/tasks.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeAppProjects serves the projects page
|
||||||
|
func (h *Handlers) ServeAppProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.serveTemplate(w, "app/projects.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeAppProject serves a single project page
|
||||||
|
func (h *Handlers) ServeAppProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.serveTemplate(w, "app/project.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeAppRequest serves a request detail page
|
||||||
|
func (h *Handlers) ServeAppRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.serveTemplate(w, "app/request.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRequestDetail handles GET /api/requests/{requestID}
|
||||||
|
func (h *Handlers) GetRequestDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
requestID := chi.URLParam(r, "requestID")
|
||||||
|
|
||||||
|
entry, err := lib.EntryByID(h.DB, h.Cfg, requestID)
|
||||||
|
if err != nil || entry == nil {
|
||||||
|
ErrorResponse(w, http.StatusNotFound, "not_found", "Request not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
if err := lib.CheckAccessRead(h.DB, actorID, entry.ProjectID, ""); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get children (answers, comments)
|
||||||
|
children, err := lib.EntriesByParent(h.DB, h.Cfg, requestID)
|
||||||
|
if err != nil {
|
||||||
|
children = []lib.Entry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusOK, map[string]any{
|
||||||
|
"request": entry,
|
||||||
|
"children": children,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,31 @@ type jwtClaims struct {
|
||||||
IssuedAt int64 `json:"iat"`
|
IssuedAt int64 `json:"iat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createJWT creates a signed JWT with the given claims.
|
||||||
|
func createJWT(userID, sessionID string, secret []byte, duration int64) (string, error) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
claims := jwtClaims{
|
||||||
|
UserID: userID,
|
||||||
|
SessionID: sessionID,
|
||||||
|
ExpiresAt: now + duration,
|
||||||
|
IssuedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||||
|
payloadJSON, err := json.Marshal(claims)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||||
|
|
||||||
|
signingInput := header + "." + payload
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(signingInput))
|
||||||
|
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
return signingInput + "." + sig, nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateJWT(token string, secret []byte) (*jwtClaims, error) {
|
func validateJWT(token string, secret []byte) (*jwtClaims, error) {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,416 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/mish/dealspace/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDBSetup(t *testing.T) (*lib.DB, *lib.Config) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "dealspace-api-test-*.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp file: %v", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
t.Cleanup(func() { os.Remove(tmpFile.Name()) })
|
||||||
|
|
||||||
|
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
masterKey := make([]byte, 32)
|
||||||
|
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
|
||||||
|
|
||||||
|
cfg := &lib.Config{
|
||||||
|
MasterKey: masterKey,
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestUserAndSession(t *testing.T, db *lib.DB, cfg *lib.Config) (*lib.User, *lib.Session) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
user := &lib.User{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Email: uuid.New().String() + "@test.com",
|
||||||
|
Name: "Test User",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := lib.UserCreate(db, user); err != nil {
|
||||||
|
t.Fatalf("UserCreate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &lib.Session{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
UserID: user.UserID,
|
||||||
|
Fingerprint: "test-fingerprint",
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now + 86400000, // +1 day
|
||||||
|
Revoked: false,
|
||||||
|
}
|
||||||
|
if err := lib.SessionCreate(db, session); err != nil {
|
||||||
|
t.Fatalf("SessionCreate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, session
|
||||||
|
}
|
||||||
|
|
||||||
|
func createJWT(userID, sessionID string, expiresAt int64, secret []byte) string {
|
||||||
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||||
|
|
||||||
|
claims := map[string]interface{}{
|
||||||
|
"sub": userID,
|
||||||
|
"sid": sessionID,
|
||||||
|
"exp": expiresAt,
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
}
|
||||||
|
claimsJSON, _ := json.Marshal(claims)
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||||
|
|
||||||
|
signingInput := header + "." + payload
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(signingInput))
|
||||||
|
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
return header + "." + payload + "." + signature
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_ValidToken(t *testing.T) {
|
||||||
|
db, cfg := testDBSetup(t)
|
||||||
|
user, session := createTestUserAndSession(t, db, cfg)
|
||||||
|
|
||||||
|
// Create valid JWT
|
||||||
|
token := createJWT(user.UserID, session.ID, time.Now().Unix()+3600, cfg.JWTSecret)
|
||||||
|
|
||||||
|
// Create test handler that checks user ID
|
||||||
|
var capturedUserID string
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedUserID = UserIDFromContext(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrap with auth middleware
|
||||||
|
wrapped := AuthMiddleware(db, cfg.JWTSecret)(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if capturedUserID != user.UserID {
|
||||||
|
t.Errorf("user ID not set correctly: got %s, want %s", capturedUserID, user.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_NoToken(t *testing.T) {
|
||||||
|
db, cfg := testDBSetup(t)
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := AuthMiddleware(db, cfg.JWTSecret)(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
// No Authorization header
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_ExpiredToken(t *testing.T) {
|
||||||
|
db, cfg := testDBSetup(t)
|
||||||
|
user, session := createTestUserAndSession(t, db, cfg)
|
||||||
|
|
||||||
|
// Create expired JWT (expired 1 hour ago)
|
||||||
|
token := createJWT(user.UserID, session.ID, time.Now().Unix()-3600, cfg.JWTSecret)
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := AuthMiddleware(db, cfg.JWTSecret)(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401 for expired token, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_InvalidToken(t *testing.T) {
|
||||||
|
db, cfg := testDBSetup(t)
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := AuthMiddleware(db, cfg.JWTSecret)(handler)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
}{
|
||||||
|
{"garbage", "not-a-jwt"},
|
||||||
|
{"malformed", "a.b.c.d.e"},
|
||||||
|
{"wrong signature", createJWT("user", "session", time.Now().Unix()+3600, []byte("wrong-secret"))},
|
||||||
|
{"empty bearer", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
if tc.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tc.token)
|
||||||
|
} else {
|
||||||
|
req.Header.Set("Authorization", "Bearer ")
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_RevokedSession(t *testing.T) {
|
||||||
|
db, cfg := testDBSetup(t)
|
||||||
|
user, session := createTestUserAndSession(t, db, cfg)
|
||||||
|
|
||||||
|
// Create valid JWT
|
||||||
|
token := createJWT(user.UserID, session.ID, time.Now().Unix()+3600, cfg.JWTSecret)
|
||||||
|
|
||||||
|
// Revoke the session
|
||||||
|
if err := lib.SessionRevoke(db, session.ID); err != nil {
|
||||||
|
t.Fatalf("SessionRevoke: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := AuthMiddleware(db, cfg.JWTSecret)(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401 for revoked session, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_ExpiredSession(t *testing.T) {
|
||||||
|
db, cfg := testDBSetup(t)
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
user := &lib.User{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Email: uuid.New().String() + "@test.com",
|
||||||
|
Name: "Test User",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
lib.UserCreate(db, user)
|
||||||
|
|
||||||
|
// Create session that's already expired
|
||||||
|
session := &lib.Session{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
UserID: user.UserID,
|
||||||
|
Fingerprint: "test-fingerprint",
|
||||||
|
CreatedAt: now - 86400000*2, // 2 days ago
|
||||||
|
ExpiresAt: now - 86400000, // expired 1 day ago
|
||||||
|
Revoked: false,
|
||||||
|
}
|
||||||
|
lib.SessionCreate(db, session)
|
||||||
|
|
||||||
|
// Create JWT that hasn't expired (but session has)
|
||||||
|
token := createJWT(user.UserID, session.ID, time.Now().Unix()+3600, cfg.JWTSecret)
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := AuthMiddleware(db, cfg.JWTSecret)(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401 for expired session, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSMiddleware(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := CORSMiddleware(handler)
|
||||||
|
|
||||||
|
// Regular request
|
||||||
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||||
|
t.Error("CORS header not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight request
|
||||||
|
req = httptest.NewRequest("OPTIONS", "/api/test", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("OPTIONS should return 204, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Access-Control-Allow-Methods") == "" {
|
||||||
|
t.Error("Allow-Methods header not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggingMiddleware(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := LoggingMiddleware(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected 201, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitMiddleware(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Very low limit for testing
|
||||||
|
wrapped := RateLimitMiddleware(3)(handler)
|
||||||
|
|
||||||
|
// First 3 requests should succeed
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.1:12345"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("request %d should succeed, got %d", i+1, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th request should be rate limited
|
||||||
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.1:12345"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("4th request should be rate limited, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different IP should succeed
|
||||||
|
req = httptest.NewRequest("GET", "/api/test", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.2:12345"
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("different IP should succeed, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorResponse(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ErrorResponse(rec, http.StatusBadRequest, "bad_request", "Invalid input")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp["code"] != "bad_request" {
|
||||||
|
t.Errorf("expected code 'bad_request', got %s", resp["code"])
|
||||||
|
}
|
||||||
|
if resp["error"] != "Invalid input" {
|
||||||
|
t.Errorf("expected error 'Invalid input', got %s", resp["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONResponse(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"id": 123,
|
||||||
|
"name": "test",
|
||||||
|
}
|
||||||
|
JSONResponse(rec, http.StatusOK, data)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Header().Get("Content-Type") != "application/json" {
|
||||||
|
t.Error("Content-Type should be application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp["name"] != "test" {
|
||||||
|
t.Error("response data incorrect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Dealspace Backup Script
|
||||||
|
# Runs daily via cron, keeps last 30 backups
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BACKUP_DIR=/opt/dealspace/backups
|
||||||
|
DB_PATH=/opt/dealspace/data/dealspace.db
|
||||||
|
LOG_FILE=/opt/dealspace/logs/backup.log
|
||||||
|
NTFY_URL="https://ntfy.inou.com/inou-alerts"
|
||||||
|
NTFY_TOKEN="tk_k120jegay3lugeqbr9fmpuxdqmzx5"
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
BACKUP_FILE="dealspace-${TIMESTAMP}.db"
|
||||||
|
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_FILE}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
alert() {
|
||||||
|
curl -sf "${NTFY_URL}" \
|
||||||
|
-H "Authorization: Bearer ${NTFY_TOKEN}" \
|
||||||
|
-H "Title: Dealspace Backup FAILED" \
|
||||||
|
-H "Priority: high" \
|
||||||
|
-H "Tags: warning" \
|
||||||
|
-d "$1" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
log "Starting backup..."
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
MSG="Database not found: $DB_PATH"
|
||||||
|
log "ERROR: $MSG"
|
||||||
|
alert "$MSG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Perform SQLite backup (hot backup, safe with WAL)
|
||||||
|
if ! sqlite3 "$DB_PATH" ".backup '$BACKUP_PATH'"; then
|
||||||
|
MSG="SQLite backup command failed"
|
||||||
|
log "ERROR: $MSG"
|
||||||
|
alert "$MSG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify backup was created
|
||||||
|
if [ ! -f "$BACKUP_PATH" ]; then
|
||||||
|
MSG="Backup file not created: $BACKUP_PATH"
|
||||||
|
log "ERROR: $MSG"
|
||||||
|
alert "$MSG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compress backup
|
||||||
|
if ! gzip "$BACKUP_PATH"; then
|
||||||
|
MSG="Failed to compress backup"
|
||||||
|
log "ERROR: $MSG"
|
||||||
|
alert "$MSG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_SIZE=$(du -h "${BACKUP_PATH}.gz" | cut -f1)
|
||||||
|
log "Backup created: ${BACKUP_FILE}.gz ($BACKUP_SIZE)"
|
||||||
|
|
||||||
|
# Clean up old backups (keep last 30)
|
||||||
|
OLD_COUNT=$(find "$BACKUP_DIR" -name "dealspace-*.db.gz" -type f -mtime +$RETENTION_DAYS | wc -l)
|
||||||
|
if [ "$OLD_COUNT" -gt 0 ]; then
|
||||||
|
find "$BACKUP_DIR" -name "dealspace-*.db.gz" -type f -mtime +$RETENTION_DAYS -delete
|
||||||
|
log "Deleted $OLD_COUNT old backups (older than $RETENTION_DAYS days)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count remaining backups
|
||||||
|
BACKUP_COUNT=$(find "$BACKUP_DIR" -name "dealspace-*.db.gz" -type f | wc -l)
|
||||||
|
log "Backup complete. $BACKUP_COUNT backups retained."
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Dealspace M&A Platform
|
||||||
|
After=network.target
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=johan
|
||||||
|
WorkingDirectory=/opt/dealspace
|
||||||
|
EnvironmentFile=/opt/dealspace/.env
|
||||||
|
ExecStart=/opt/dealspace/bin/dealspace
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=dealspace
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/opt/dealspace/data /opt/dealspace/store /opt/dealspace/logs
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Dealspace Environment Configuration
|
||||||
|
# Copy to /opt/dealspace/.env and fill in values
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Core
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 32 bytes hex — NEVER CHANGE after data is written!
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
MASTER_KEY=
|
||||||
|
|
||||||
|
# Database path (SQLite with FTS5)
|
||||||
|
DB_PATH=/opt/dealspace/data/dealspace.db
|
||||||
|
|
||||||
|
# Object store path (encrypted files)
|
||||||
|
STORE_PATH=/opt/dealspace/store
|
||||||
|
|
||||||
|
# HTTP port
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Environment: development | production
|
||||||
|
ENV=production
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Auth
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Session token TTL (hours)
|
||||||
|
SESSION_TTL_HOURS=1
|
||||||
|
|
||||||
|
# Refresh token TTL (days)
|
||||||
|
REFRESH_TTL_DAYS=7
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Seeding
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Set to true on first run to seed demo data, then remove
|
||||||
|
SEED_DEMO=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Email (Stalwart SMTP at mail.jongsma.me)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SMTP_HOST=mail.jongsma.me
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=noreply@muskepo.com
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AI (Fireworks — zero retention, FIPS compliant)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
FIREWORKS_API_KEY=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Monitoring
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ntfy notifications for alerts
|
||||||
|
NTFY_URL=https://ntfy.inou.com/inou-alerts
|
||||||
|
NTFY_TOKEN=tk_k120jegay3lugeqbr9fmpuxdqmzx5
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Dealspace Healthcheck Script
|
||||||
|
# Runs every 5 minutes via cron, alerts via ntfy if down
|
||||||
|
|
||||||
|
HEALTH=$(curl -sf --max-time 5 http://localhost:8080/health | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$HEALTH" != "ok" ]; then
|
||||||
|
curl -s https://ntfy.inou.com/inou-alerts \
|
||||||
|
-H "Authorization: Bearer tk_k120jegay3lugeqbr9fmpuxdqmzx5" \
|
||||||
|
-H "Title: Dealspace DOWN" \
|
||||||
|
-H "Priority: urgent" \
|
||||||
|
-H "Tags: warning" \
|
||||||
|
-d "Dealspace health check failed on Shannon (82.24.174.112)"
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Dealspace Installation Script
|
||||||
|
# Run as root on Shannon (82.24.174.112)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
INSTALL_DIR=/opt/dealspace
|
||||||
|
SERVICE_USER=johan
|
||||||
|
|
||||||
|
echo "=== Dealspace Installation ==="
|
||||||
|
|
||||||
|
# Create user if missing
|
||||||
|
if ! id "$SERVICE_USER" &>/dev/null; then
|
||||||
|
echo "Creating user $SERVICE_USER..."
|
||||||
|
useradd -r -s /bin/bash -d /home/$SERVICE_USER -m $SERVICE_USER
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directory structure
|
||||||
|
echo "Creating directories..."
|
||||||
|
mkdir -p $INSTALL_DIR/{bin,data,store,logs,backups,migrations}
|
||||||
|
|
||||||
|
# Generate MASTER_KEY if .env doesn't exist
|
||||||
|
if [ ! -f "$INSTALL_DIR/.env" ]; then
|
||||||
|
echo "Creating .env with new MASTER_KEY..."
|
||||||
|
MASTER_KEY=$(openssl rand -hex 32)
|
||||||
|
cat > $INSTALL_DIR/.env <<ENVEOF
|
||||||
|
# Core
|
||||||
|
MASTER_KEY=$MASTER_KEY
|
||||||
|
DB_PATH=$INSTALL_DIR/data/dealspace.db
|
||||||
|
STORE_PATH=$INSTALL_DIR/store
|
||||||
|
PORT=8080
|
||||||
|
ENV=production
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
SESSION_TTL_HOURS=1
|
||||||
|
REFRESH_TTL_DAYS=7
|
||||||
|
|
||||||
|
# Seeding (set to true on first run only, then remove)
|
||||||
|
SEED_DEMO=false
|
||||||
|
|
||||||
|
# Email (Stalwart SMTP at mail.jongsma.me)
|
||||||
|
SMTP_HOST=mail.jongsma.me
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=noreply@muskepo.com
|
||||||
|
|
||||||
|
# AI (Fireworks — zero retention)
|
||||||
|
FIREWORKS_API_KEY=
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
NTFY_URL=https://ntfy.inou.com/inou-alerts
|
||||||
|
NTFY_TOKEN=tk_k120jegay3lugeqbr9fmpuxdqmzx5
|
||||||
|
ENVEOF
|
||||||
|
chmod 600 $INSTALL_DIR/.env
|
||||||
|
echo "⚠️ MASTER_KEY generated. Back it up securely. NEVER CHANGE after data is written."
|
||||||
|
else
|
||||||
|
echo ".env already exists, skipping..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
echo "Setting permissions..."
|
||||||
|
chown -R $SERVICE_USER:$SERVICE_USER $INSTALL_DIR
|
||||||
|
chmod 755 $INSTALL_DIR
|
||||||
|
chmod 700 $INSTALL_DIR/data $INSTALL_DIR/store $INSTALL_DIR/backups
|
||||||
|
chmod 755 $INSTALL_DIR/bin $INSTALL_DIR/logs
|
||||||
|
|
||||||
|
# Install systemd service
|
||||||
|
echo "Installing systemd service..."
|
||||||
|
cp "$(dirname "$0")/dealspace.service" /etc/systemd/system/dealspace.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable dealspace
|
||||||
|
|
||||||
|
# Install backup script
|
||||||
|
echo "Installing backup script..."
|
||||||
|
cp "$(dirname "$0")/backup.sh" $INSTALL_DIR/backup.sh
|
||||||
|
chmod +x $INSTALL_DIR/backup.sh
|
||||||
|
|
||||||
|
# Install healthcheck script
|
||||||
|
echo "Installing healthcheck script..."
|
||||||
|
cp "$(dirname "$0")/healthcheck.sh" $INSTALL_DIR/healthcheck.sh
|
||||||
|
chmod +x $INSTALL_DIR/healthcheck.sh
|
||||||
|
|
||||||
|
# Install cron jobs
|
||||||
|
echo "Installing cron jobs..."
|
||||||
|
CRON_TMP=$(mktemp)
|
||||||
|
|
||||||
|
# Backup: daily at 3 AM
|
||||||
|
# Healthcheck: every 5 minutes
|
||||||
|
cat > $CRON_TMP <<CRONEOF
|
||||||
|
# Dealspace backup - daily at 3 AM
|
||||||
|
0 3 * * * $INSTALL_DIR/backup.sh >> $INSTALL_DIR/logs/backup.log 2>&1
|
||||||
|
|
||||||
|
# Dealspace healthcheck - every 5 minutes
|
||||||
|
*/5 * * * * $INSTALL_DIR/healthcheck.sh
|
||||||
|
CRONEOF
|
||||||
|
|
||||||
|
crontab -u $SERVICE_USER $CRON_TMP
|
||||||
|
rm $CRON_TMP
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installation Complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Edit $INSTALL_DIR/.env with SMTP and Fireworks credentials"
|
||||||
|
echo " 2. Deploy the binary: make deploy"
|
||||||
|
echo " 3. Start the service: systemctl start dealspace"
|
||||||
|
echo " 4. Check status: systemctl status dealspace"
|
||||||
|
echo " 5. View logs: journalctl -u dealspace -f"
|
||||||
|
echo ""
|
||||||
|
echo "Cron jobs installed:"
|
||||||
|
echo " - Daily backup at 3 AM"
|
||||||
|
echo " - Healthcheck every 5 minutes"
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
# Data Retention Policy
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Effective:** February 2026
|
||||||
|
**Owner:** Johan Jongsma
|
||||||
|
**Review:** Annually
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Define how long Dealspace retains client data and the procedures for data deletion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
All data stored in Dealspace systems:
|
||||||
|
- Projects and deals
|
||||||
|
- Deal data (requests, responses, documents)
|
||||||
|
- Participant accounts and access grants
|
||||||
|
- Access logs
|
||||||
|
- Authentication tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Retention Periods
|
||||||
|
|
||||||
|
### Deal Data
|
||||||
|
|
||||||
|
| Data Type | Retention Period | Rationale |
|
||||||
|
|-----------|------------------|-----------|
|
||||||
|
| Active deal data | Per client agreement | Deal lifecycle varies |
|
||||||
|
| Closed deals | 7 years from close | Regulatory compliance |
|
||||||
|
| Deleted deals | 30 days (soft delete), then purged | Recovery window |
|
||||||
|
|
||||||
|
### System Data
|
||||||
|
|
||||||
|
| Data Type | Retention Period | Rationale |
|
||||||
|
|-----------|------------------|-----------|
|
||||||
|
| HTTP access logs | 90 days | Security investigation window |
|
||||||
|
| Audit logs | 7 years | Regulatory compliance |
|
||||||
|
| Error logs | 90 days | Debugging and monitoring |
|
||||||
|
|
||||||
|
### Authentication Data
|
||||||
|
|
||||||
|
| Data Type | Retention Period | Rationale |
|
||||||
|
|-----------|------------------|-----------|
|
||||||
|
| Access tokens | 1 hour expiry | Security |
|
||||||
|
| Refresh tokens | 7 days or until revoked | Session management |
|
||||||
|
| Invite tokens | 72 hours or until used | Security |
|
||||||
|
|
||||||
|
### Backup Data
|
||||||
|
|
||||||
|
| Data Type | Retention Period | Rationale |
|
||||||
|
|-----------|------------------|-----------|
|
||||||
|
| Daily backups | 30 days | Recovery window |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Client-Initiated Deletion
|
||||||
|
|
||||||
|
### Project Deletion
|
||||||
|
|
||||||
|
When a client deletes a project:
|
||||||
|
|
||||||
|
**Immediate actions:**
|
||||||
|
- Mark project as deleted
|
||||||
|
- Revoke all access grants
|
||||||
|
- Remove from active listings
|
||||||
|
|
||||||
|
**Within 30 days:**
|
||||||
|
- Soft delete allows recovery
|
||||||
|
- After 30 days: permanent purge
|
||||||
|
|
||||||
|
**Retained for compliance:**
|
||||||
|
- Audit log entries (7 years, anonymized)
|
||||||
|
|
||||||
|
### Individual Entry Deletion
|
||||||
|
|
||||||
|
When a user deletes a specific entry:
|
||||||
|
- Entry soft-deleted immediately
|
||||||
|
- Removed from backups per rotation schedule (30 days)
|
||||||
|
|
||||||
|
### Right to Erasure (GDPR Article 17)
|
||||||
|
|
||||||
|
Users may request complete erasure:
|
||||||
|
|
||||||
|
1. User submits request via privacy@muskepo.com
|
||||||
|
2. Identity verified
|
||||||
|
3. Deletion executed within 30 days
|
||||||
|
4. Confirmation sent to user
|
||||||
|
5. Request logged for compliance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Automated Retention Enforcement
|
||||||
|
|
||||||
|
### Daily Cleanup Jobs
|
||||||
|
|
||||||
|
- Remove expired access tokens
|
||||||
|
- Remove expired refresh tokens
|
||||||
|
- Remove expired invite tokens
|
||||||
|
- Process queued deletions past retention window
|
||||||
|
|
||||||
|
### Log Rotation
|
||||||
|
|
||||||
|
- Rotate logs older than 90 days
|
||||||
|
- Audit logs retained for 7 years
|
||||||
|
|
||||||
|
### Backup Rotation
|
||||||
|
|
||||||
|
- Daily backups: 30-day retention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Legal Holds
|
||||||
|
|
||||||
|
When litigation or investigation requires data preservation:
|
||||||
|
|
||||||
|
1. **Identify scope** - Which clients/deals affected
|
||||||
|
2. **Suspend deletion** - Exclude from automated purges
|
||||||
|
3. **Document hold** - Record reason, scope, authorizer, date
|
||||||
|
4. **Release hold** - When legal matter resolved, resume normal retention
|
||||||
|
|
||||||
|
**Current legal holds:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Data Export
|
||||||
|
|
||||||
|
Clients may export their data at any time:
|
||||||
|
- Full export available via platform
|
||||||
|
- Formats: JSON (structured data), original files
|
||||||
|
- Export includes all project data and audit logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Backup Data Handling
|
||||||
|
|
||||||
|
Deleted data may persist in backups until rotation completes:
|
||||||
|
|
||||||
|
| Backup Type | Maximum Persistence After Deletion |
|
||||||
|
|-------------|-----------------------------------|
|
||||||
|
| Daily backups | 30 days |
|
||||||
|
|
||||||
|
Clients are informed that complete purge from all backups occurs within 30 days of deletion request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Third-Party Data
|
||||||
|
|
||||||
|
### Hostkey (Hosting)
|
||||||
|
|
||||||
|
- Encrypted data only
|
||||||
|
- Subject to Dealspace's retention policies
|
||||||
|
- Physical media destroyed per Hostkey procedures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Compliance Mapping
|
||||||
|
|
||||||
|
| Regulation | Requirement | Implementation |
|
||||||
|
|------------|-------------|----------------|
|
||||||
|
| GDPR Art. 17 | Right to erasure | 30-day deletion on request |
|
||||||
|
| GDPR Art. 5(1)(e) | Storage limitation | Defined retention periods |
|
||||||
|
| FADP | Data minimization | Same as GDPR implementation |
|
||||||
|
| CCPA | Deletion rights | Same as GDPR implementation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Verification
|
||||||
|
|
||||||
|
### Monthly Review
|
||||||
|
|
||||||
|
- [ ] Verify cleanup jobs running
|
||||||
|
- [ ] Check for orphaned data
|
||||||
|
- [ ] Review pending deletion requests
|
||||||
|
- [ ] Confirm backup rotation operating
|
||||||
|
|
||||||
|
### Annual Review
|
||||||
|
|
||||||
|
- [ ] Review retention periods for regulatory changes
|
||||||
|
- [ ] Update policy as needed
|
||||||
|
- [ ] Verify compliance with stated periods
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document end*
|
||||||
|
|
@ -0,0 +1,300 @@
|
||||||
|
# Disaster Recovery Plan
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Effective:** February 2026
|
||||||
|
**Owner:** Johan Jongsma
|
||||||
|
**Review:** Annually
|
||||||
|
**Last DR Test:** Not yet performed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Define procedures to recover Dealspace services and data following a disaster affecting production systems.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
| System | Location | Criticality |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| Production server | 82.24.174.112 (Zürich) | Critical |
|
||||||
|
| Database | /opt/dealspace/data/dealspace.db | Critical |
|
||||||
|
| Master encryption key | Secure storage | Critical |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Recovery Objectives
|
||||||
|
|
||||||
|
| Metric | Target |
|
||||||
|
|--------|--------|
|
||||||
|
| **RTO** (Recovery Time Objective) | 4 hours |
|
||||||
|
| **RPO** (Recovery Point Objective) | 24 hours |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Backup Strategy
|
||||||
|
|
||||||
|
### Backup Inventory
|
||||||
|
|
||||||
|
| Data | Method | Frequency | Retention | Location |
|
||||||
|
|------|--------|-----------|-----------|----------|
|
||||||
|
| Database | SQLite backup | Daily | 30 days | Encrypted off-site |
|
||||||
|
| Master key | Manual copy | On change | Permanent | Separate secure storage |
|
||||||
|
| Configuration | Git repository | Per change | Permanent | Remote repository |
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
All data is encrypted before leaving the server:
|
||||||
|
- Database fields: AES-256-GCM encryption with per-project keys
|
||||||
|
- Off-site backups: Already encrypted
|
||||||
|
- Master key: Stored separately from data backups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Disaster Scenarios
|
||||||
|
|
||||||
|
### Scenario A: Hardware Failure (Single Component)
|
||||||
|
|
||||||
|
**Symptoms:** Server unresponsive, network failure
|
||||||
|
|
||||||
|
**Recovery:**
|
||||||
|
1. Contact Hostkey support
|
||||||
|
2. Restore from backup to new VPS if needed
|
||||||
|
3. Verify services: health check endpoint
|
||||||
|
4. Update DNS if IP changed
|
||||||
|
|
||||||
|
**Estimated time:** 2-4 hours
|
||||||
|
|
||||||
|
### Scenario B: Database Corruption
|
||||||
|
|
||||||
|
**Symptoms:** Application errors, SQLite integrity failures
|
||||||
|
|
||||||
|
**Recovery:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop services
|
||||||
|
ssh root@82.24.174.112 "systemctl stop dealspace"
|
||||||
|
|
||||||
|
# 2. Backup corrupted DB for analysis
|
||||||
|
ssh root@82.24.174.112 "cp /opt/dealspace/data/dealspace.db /opt/dealspace/data/dealspace.db.corrupted"
|
||||||
|
|
||||||
|
# 3. Restore from backup
|
||||||
|
# Download latest backup and restore
|
||||||
|
scp backup-server:/backups/dealspace-latest.db.enc /tmp/
|
||||||
|
# Decrypt and place in position
|
||||||
|
|
||||||
|
# 4. Restart services
|
||||||
|
ssh root@82.24.174.112 "systemctl start dealspace"
|
||||||
|
|
||||||
|
# 5. Verify
|
||||||
|
curl -s https://muskepo.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estimated time:** 1-2 hours
|
||||||
|
|
||||||
|
### Scenario C: Complete Server Loss
|
||||||
|
|
||||||
|
**Symptoms:** Server destroyed, stolen, or unrecoverable
|
||||||
|
|
||||||
|
**Recovery:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Provision new VPS at Hostkey
|
||||||
|
# 2. Apply OS hardening (see security-policy.md)
|
||||||
|
|
||||||
|
# 3. Create directory structure
|
||||||
|
mkdir -p /opt/dealspace/{bin,data}
|
||||||
|
|
||||||
|
# 4. Restore master key from secure storage
|
||||||
|
# Copy 32-byte key to secure location
|
||||||
|
chmod 600 /opt/dealspace/master.key
|
||||||
|
|
||||||
|
# 5. Restore database from backup
|
||||||
|
# Download encrypted backup
|
||||||
|
# Decrypt and place at /opt/dealspace/data/dealspace.db
|
||||||
|
|
||||||
|
# 6. Deploy application binary
|
||||||
|
scp dealspace-linux root@NEW_IP:/opt/dealspace/bin/dealspace
|
||||||
|
chmod +x /opt/dealspace/bin/dealspace
|
||||||
|
|
||||||
|
# 7. Configure systemd service
|
||||||
|
# 8. Start service
|
||||||
|
# 9. Update DNS to new IP
|
||||||
|
|
||||||
|
# 10. Verify
|
||||||
|
curl -s https://muskepo.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estimated time:** 4-8 hours
|
||||||
|
|
||||||
|
### Scenario D: Ransomware/Compromise
|
||||||
|
|
||||||
|
**Symptoms:** Encrypted files, unauthorized access, system tampering
|
||||||
|
|
||||||
|
**Recovery:**
|
||||||
|
1. **Do not use compromised system** - assume attacker persistence
|
||||||
|
2. Provision fresh VPS from scratch
|
||||||
|
3. Restore from known-good backup (before compromise date)
|
||||||
|
4. Rotate master key and re-encrypt all data
|
||||||
|
5. Rotate all credentials
|
||||||
|
6. Apply additional hardening
|
||||||
|
7. Monitor closely for re-compromise
|
||||||
|
|
||||||
|
**Estimated time:** 8-24 hours
|
||||||
|
|
||||||
|
### Scenario E: Provider/Region Loss
|
||||||
|
|
||||||
|
**Symptoms:** Hostkey Zürich unavailable
|
||||||
|
|
||||||
|
**Recovery:**
|
||||||
|
1. Provision new VPS at alternate provider
|
||||||
|
2. Restore from off-site backup
|
||||||
|
3. Restore master key from secure storage
|
||||||
|
4. Deploy application
|
||||||
|
5. Update DNS
|
||||||
|
|
||||||
|
**Estimated time:** 24-48 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Key Management
|
||||||
|
|
||||||
|
### Master Key Recovery
|
||||||
|
|
||||||
|
The master key is **critical**. Without it, all encrypted data is permanently unrecoverable.
|
||||||
|
|
||||||
|
**Storage locations:**
|
||||||
|
1. Production server: Secure location
|
||||||
|
2. Secure backup: Separate secure storage (not with data backups)
|
||||||
|
|
||||||
|
**Recovery procedure:**
|
||||||
|
1. Retrieve the 32-byte master key from secure storage
|
||||||
|
2. Create file with proper permissions
|
||||||
|
3. Verify length (must be exactly 32 bytes)
|
||||||
|
|
||||||
|
### Key Rotation (If Compromised)
|
||||||
|
|
||||||
|
If the master key may be compromised:
|
||||||
|
|
||||||
|
1. Generate new master key
|
||||||
|
2. Run re-encryption migration (decrypt with old key, re-encrypt with new)
|
||||||
|
3. Replace key file
|
||||||
|
4. Update secure storage with new key
|
||||||
|
5. Verify application functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Recovery Procedures
|
||||||
|
|
||||||
|
### Pre-Recovery Checklist
|
||||||
|
|
||||||
|
- [ ] Incident documented and severity assessed
|
||||||
|
- [ ] Stakeholders notified
|
||||||
|
- [ ] Backup integrity verified
|
||||||
|
- [ ] Recovery environment prepared
|
||||||
|
- [ ] Master key accessible
|
||||||
|
|
||||||
|
### Database Restore from Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop services
|
||||||
|
ssh root@82.24.174.112 "systemctl stop dealspace"
|
||||||
|
|
||||||
|
# Download and decrypt backup
|
||||||
|
# Place at /opt/dealspace/data/dealspace.db
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
ssh root@82.24.174.112 "systemctl start dealspace"
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
curl -s https://muskepo.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Communication During Disaster
|
||||||
|
|
||||||
|
| Audience | Method | Message |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| Clients | Email + status page | "Dealspace is experiencing technical difficulties. We expect to restore service by [time]." |
|
||||||
|
| Affected clients | Direct email | Per incident response plan if data affected |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Testing Schedule
|
||||||
|
|
||||||
|
| Test Type | Frequency | Last Performed | Next Due |
|
||||||
|
|-----------|-----------|----------------|----------|
|
||||||
|
| Backup verification | Monthly | Not yet | March 2026 |
|
||||||
|
| Database restore | Quarterly | Not yet | Q1 2026 |
|
||||||
|
| Full DR drill | Annually | Not yet | Q4 2026 |
|
||||||
|
|
||||||
|
### Backup Verification Procedure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Monthly: Verify backups exist and are readable
|
||||||
|
# List available backups
|
||||||
|
# Verify database integrity of latest backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore Test Procedure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quarterly: Restore to test environment and verify
|
||||||
|
|
||||||
|
# 1. Download backup to test environment
|
||||||
|
# 2. Verify database integrity: sqlite3 test.db "PRAGMA integrity_check"
|
||||||
|
# 3. Verify data is readable (requires master key)
|
||||||
|
# 4. Document results
|
||||||
|
# 5. Clean up test files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Post-Recovery Checklist
|
||||||
|
|
||||||
|
After any recovery:
|
||||||
|
|
||||||
|
- [ ] All services operational (health check passes)
|
||||||
|
- [ ] Data integrity verified (spot-check records)
|
||||||
|
- [ ] Logs reviewed for errors
|
||||||
|
- [ ] Clients notified if there was visible outage
|
||||||
|
- [ ] Incident documented
|
||||||
|
- [ ] Post-mortem scheduled if significant event
|
||||||
|
- [ ] This plan updated if gaps discovered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Quick Reference
|
||||||
|
|
||||||
|
### Critical Paths
|
||||||
|
|
||||||
|
| Item | Path |
|
||||||
|
|------|------|
|
||||||
|
| Database | /opt/dealspace/data/dealspace.db |
|
||||||
|
| Binary | /opt/dealspace/bin/dealspace |
|
||||||
|
| Master key | Secure location |
|
||||||
|
|
||||||
|
### Service Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Status
|
||||||
|
ssh root@82.24.174.112 "systemctl status dealspace"
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
ssh root@82.24.174.112 "systemctl stop dealspace"
|
||||||
|
|
||||||
|
# Start
|
||||||
|
ssh root@82.24.174.112 "systemctl start dealspace"
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
ssh root@82.24.174.112 "journalctl -u dealspace -f"
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
curl -s https://muskepo.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document end*
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
# Incident Response Plan
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Effective:** February 2026
|
||||||
|
**Owner:** Johan Jongsma
|
||||||
|
**Review:** Annually
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Define procedures for detecting, responding to, and recovering from security incidents affecting Dealspace systems or deal data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
All Dealspace systems:
|
||||||
|
- Production (muskepo.com / 82.24.174.112)
|
||||||
|
- Deal data (financial documents, transaction details, participant information)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Incident Classification
|
||||||
|
|
||||||
|
| Severity | Definition | Response Time | Examples |
|
||||||
|
|----------|------------|---------------|----------|
|
||||||
|
| **Critical** | Active breach, data exfiltration, system compromise | Immediate (< 1 hour) | Unauthorized deal data access, ransomware, credential theft |
|
||||||
|
| **High** | Potential breach, service outage, vulnerability exploited | < 4 hours | Failed intrusion attempt, DDoS, authentication bypass |
|
||||||
|
| **Medium** | Suspicious activity, policy violation | < 24 hours | Unusual access patterns, failed login spikes |
|
||||||
|
| **Low** | Minor issue, no data at risk | < 72 hours | Reconnaissance scans, policy clarification needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Contact Information
|
||||||
|
|
||||||
|
### Primary Contact
|
||||||
|
|
||||||
|
| Role | Name | Email | Phone |
|
||||||
|
|------|------|-------|-------|
|
||||||
|
| Incident Commander | Johan Jongsma | security@muskepo.com | Signal: +31 XXX |
|
||||||
|
|
||||||
|
### External Contacts
|
||||||
|
|
||||||
|
| Service | Contact |
|
||||||
|
|---------|---------|
|
||||||
|
| Legal Counsel | To be established |
|
||||||
|
| Cyber Insurance | To be established |
|
||||||
|
| Law Enforcement | Local police non-emergency |
|
||||||
|
|
||||||
|
### Notification Addresses
|
||||||
|
|
||||||
|
| Purpose | Address |
|
||||||
|
|---------|---------|
|
||||||
|
| Security incidents | security@muskepo.com |
|
||||||
|
| Client support | support@muskepo.com |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Detection
|
||||||
|
|
||||||
|
### Automated Detection
|
||||||
|
|
||||||
|
- **Rate limiting:** Flags excessive requests
|
||||||
|
- **Access logging:** All data access logged
|
||||||
|
- **Anomaly detection:** Unusual access patterns flagged
|
||||||
|
- **Authentication monitoring:** Failed login tracking
|
||||||
|
|
||||||
|
### Manual Detection
|
||||||
|
|
||||||
|
- Client reports of unauthorized access
|
||||||
|
- Unexpected system behavior
|
||||||
|
- External notification (security researcher, vendor)
|
||||||
|
|
||||||
|
### Indicators of Compromise
|
||||||
|
|
||||||
|
- Unexpected admin access or privilege escalation
|
||||||
|
- Unusual database queries or data exports
|
||||||
|
- New or modified files outside deployment
|
||||||
|
- Outbound connections to unknown hosts
|
||||||
|
- Authentication anomalies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Response Procedures
|
||||||
|
|
||||||
|
### Phase 1: Identification (0-30 minutes)
|
||||||
|
|
||||||
|
1. **Acknowledge alert** - Confirm incident is real, not false positive
|
||||||
|
2. **Classify severity** - Use classification table above
|
||||||
|
3. **Document** - Start incident log with timestamp, initial observations
|
||||||
|
4. **Assess scope** - What systems/deal data potentially affected
|
||||||
|
|
||||||
|
### Phase 2: Containment (30 min - 2 hours)
|
||||||
|
|
||||||
|
**Immediate containment:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Block suspicious IP
|
||||||
|
sudo ufw deny from <IP>
|
||||||
|
|
||||||
|
# If compromise confirmed, consider service isolation
|
||||||
|
ssh root@82.24.174.112 "systemctl stop dealspace"
|
||||||
|
|
||||||
|
# Preserve logs before any changes
|
||||||
|
ssh root@82.24.174.112 "cp -r /var/log /opt/dealspace/incident-$(date +%Y%m%d)/"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Short-term containment:**
|
||||||
|
- Preserve evidence (copy logs before rotation)
|
||||||
|
- Identify scope (what deals/data affected)
|
||||||
|
- Prevent lateral movement
|
||||||
|
|
||||||
|
### Phase 3: Eradication (2-24 hours)
|
||||||
|
|
||||||
|
1. **Identify root cause** - How did attacker get in?
|
||||||
|
2. **Remove threat** - Malware, backdoors, unauthorized accounts
|
||||||
|
3. **Patch vulnerability** - Fix the entry point
|
||||||
|
4. **Verify clean** - Confirm no persistence mechanisms
|
||||||
|
|
||||||
|
### Phase 4: Recovery (24-72 hours)
|
||||||
|
|
||||||
|
1. **Restore from backup** if needed (see [Disaster Recovery Plan](disaster-recovery-plan.md))
|
||||||
|
2. **Verify integrity** - Check data hasn't been modified
|
||||||
|
3. **Monitor closely** - Watch for re-compromise
|
||||||
|
4. **Gradual restoration** - Bring services back incrementally
|
||||||
|
|
||||||
|
### Phase 5: Lessons Learned (1-2 weeks post-incident)
|
||||||
|
|
||||||
|
1. **Post-mortem** - What happened, timeline, decisions made
|
||||||
|
2. **Update documentation** - Improve detection/response
|
||||||
|
3. **Implement improvements** - Technical and procedural changes
|
||||||
|
4. **Final report** - Document for compliance records
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Communication
|
||||||
|
|
||||||
|
### Internal Communication
|
||||||
|
|
||||||
|
- Document all decisions with timestamps
|
||||||
|
- Keep incident log updated
|
||||||
|
- Use secure communication channels
|
||||||
|
|
||||||
|
### External Communication
|
||||||
|
|
||||||
|
**To affected clients (if deal data breach confirmed):**
|
||||||
|
- Notify within 72 hours (GDPR requirement)
|
||||||
|
- Include: What happened, what data affected, what we're doing, what they should do
|
||||||
|
- Template in Appendix A
|
||||||
|
|
||||||
|
**To regulators (if required):**
|
||||||
|
- GDPR: Supervisory authority within 72 hours
|
||||||
|
- FADP: Swiss DPA notification as required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Evidence Preservation
|
||||||
|
|
||||||
|
**Preserve immediately:**
|
||||||
|
- System logs
|
||||||
|
- Database state (backup)
|
||||||
|
- Network traffic captures (if available)
|
||||||
|
- Screenshots of anomalies
|
||||||
|
|
||||||
|
**Chain of custody:**
|
||||||
|
- Document who accessed evidence and when
|
||||||
|
- Store copies in secure, separate location
|
||||||
|
- Hash files to prove integrity: `sha256sum <file>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Specific Scenarios
|
||||||
|
|
||||||
|
### Scenario: Unauthorized Deal Data Access
|
||||||
|
|
||||||
|
1. Identify which project(s) accessed
|
||||||
|
2. Check audit logs for access scope
|
||||||
|
3. Determine if data was exfiltrated
|
||||||
|
4. Notify affected clients within 72 hours
|
||||||
|
5. Document for compliance
|
||||||
|
|
||||||
|
### Scenario: Ransomware
|
||||||
|
|
||||||
|
1. **Immediately isolate** affected systems (network disconnect)
|
||||||
|
2. Do NOT pay ransom
|
||||||
|
3. Assess backup integrity
|
||||||
|
4. Restore from clean backup
|
||||||
|
5. Report to law enforcement
|
||||||
|
|
||||||
|
### Scenario: DDoS Attack
|
||||||
|
|
||||||
|
1. Enable additional rate limiting
|
||||||
|
2. Block attacking IP ranges via UFW
|
||||||
|
3. Contact Hostkey if upstream filtering needed
|
||||||
|
4. Document attack characteristics
|
||||||
|
|
||||||
|
### Scenario: Vulnerability Disclosure
|
||||||
|
|
||||||
|
1. Acknowledge receipt to researcher within 24 hours
|
||||||
|
2. Validate the vulnerability
|
||||||
|
3. Develop and test fix
|
||||||
|
4. Deploy fix
|
||||||
|
5. Thank researcher, coordinate disclosure timing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Recovery Time Objectives
|
||||||
|
|
||||||
|
| Scenario | RTO | RPO |
|
||||||
|
|----------|-----|-----|
|
||||||
|
| Hardware failure | 4 hours | 24 hours |
|
||||||
|
| Data corruption | 2 hours | 24 hours |
|
||||||
|
| Security breach | 24 hours | 0 (no data loss acceptable) |
|
||||||
|
| Complete site loss | 48 hours | 24 hours |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Client Notification Template
|
||||||
|
|
||||||
|
```
|
||||||
|
Subject: Security Notice from Dealspace
|
||||||
|
|
||||||
|
Dear [Client],
|
||||||
|
|
||||||
|
We are writing to inform you of a security incident that may have affected
|
||||||
|
data associated with your organization on the Dealspace platform.
|
||||||
|
|
||||||
|
WHAT HAPPENED
|
||||||
|
[Brief description of incident and date discovered]
|
||||||
|
|
||||||
|
WHAT INFORMATION WAS INVOLVED
|
||||||
|
[Types of data potentially affected - e.g., deal documents, participant info]
|
||||||
|
|
||||||
|
WHAT WE ARE DOING
|
||||||
|
[Steps taken to address the incident and prevent recurrence]
|
||||||
|
|
||||||
|
WHAT YOU CAN DO
|
||||||
|
[Recommended actions - e.g., notify deal participants, review access logs]
|
||||||
|
|
||||||
|
FOR MORE INFORMATION
|
||||||
|
Contact us at security@muskepo.com if you have questions.
|
||||||
|
|
||||||
|
We sincerely apologize for any concern this may cause.
|
||||||
|
|
||||||
|
Johan Jongsma
|
||||||
|
Founder, Dealspace
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: Incident Log Template
|
||||||
|
|
||||||
|
```
|
||||||
|
INCIDENT ID: INC-YYYY-MM-DD-001
|
||||||
|
SEVERITY: [Critical/High/Medium/Low]
|
||||||
|
STATUS: [Active/Contained/Resolved]
|
||||||
|
|
||||||
|
TIMELINE
|
||||||
|
- YYYY-MM-DD HH:MM - Initial detection
|
||||||
|
- YYYY-MM-DD HH:MM - [Action taken]
|
||||||
|
- YYYY-MM-DD HH:MM - [Action taken]
|
||||||
|
|
||||||
|
DESCRIPTION
|
||||||
|
[What happened]
|
||||||
|
|
||||||
|
AFFECTED SYSTEMS
|
||||||
|
[List systems]
|
||||||
|
|
||||||
|
AFFECTED DATA
|
||||||
|
[Description of data, deals/clients if known]
|
||||||
|
|
||||||
|
ROOT CAUSE
|
||||||
|
[How it happened]
|
||||||
|
|
||||||
|
RESOLUTION
|
||||||
|
[How it was fixed]
|
||||||
|
|
||||||
|
LESSONS LEARNED
|
||||||
|
[Improvements identified]
|
||||||
|
|
||||||
|
REPORTED BY: [Name]
|
||||||
|
RESOLVED BY: [Name]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document end*
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
# Risk Assessment
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Assessment Date:** February 2026
|
||||||
|
**Assessor:** Johan Jongsma
|
||||||
|
**Next Review:** February 2027
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Identify, assess, and document risks to Dealspace systems and data, and the controls in place to mitigate them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
- Dealspace production systems
|
||||||
|
- M&A deal data (financial documents, transaction details)
|
||||||
|
- Supporting infrastructure and processes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Risk Assessment Methodology
|
||||||
|
|
||||||
|
### Likelihood Scale
|
||||||
|
|
||||||
|
| Rating | Description | Frequency |
|
||||||
|
|--------|-------------|-----------|
|
||||||
|
| 1 - Rare | Unlikely to occur | < 1% annually |
|
||||||
|
| 2 - Unlikely | Could occur | 1-10% annually |
|
||||||
|
| 3 - Possible | Might occur | 10-50% annually |
|
||||||
|
| 4 - Likely | Will probably occur | 50-90% annually |
|
||||||
|
| 5 - Almost Certain | Expected to occur | > 90% annually |
|
||||||
|
|
||||||
|
### Impact Scale
|
||||||
|
|
||||||
|
| Rating | Description | Effect |
|
||||||
|
|--------|-------------|--------|
|
||||||
|
| 1 - Negligible | Minimal impact | Minor inconvenience |
|
||||||
|
| 2 - Minor | Limited impact | Some users affected, quick recovery |
|
||||||
|
| 3 - Moderate | Significant impact | Service degraded, data at risk |
|
||||||
|
| 4 - Major | Serious impact | Extended outage, data breach |
|
||||||
|
| 5 - Catastrophic | Severe impact | Complete data loss, regulatory action, criminal exposure |
|
||||||
|
|
||||||
|
### Risk Score
|
||||||
|
|
||||||
|
**Score = Likelihood x Impact** (Range: 1-25)
|
||||||
|
|
||||||
|
| Score | Level | Response |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| 1-4 | Low | Accept |
|
||||||
|
| 5-9 | Medium | Monitor |
|
||||||
|
| 10-16 | High | Mitigate |
|
||||||
|
| 17-25 | Critical | Immediate action |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Risk Register
|
||||||
|
|
||||||
|
### 4.1 Security Risks
|
||||||
|
|
||||||
|
| ID | Risk | L | I | Score | Controls | Residual |
|
||||||
|
|----|------|---|---|-------|----------|----------|
|
||||||
|
| S1 | Unauthorized deal data access | 2 | 5 | 10 | RBAC, per-project encryption, JWT auth, audit logging | Low |
|
||||||
|
| S2 | Application vulnerability exploited | 2 | 5 | 10 | Parameterized queries, input validation, rate limiting | Low |
|
||||||
|
| S3 | Credential theft/phishing | 2 | 4 | 8 | MFA for IB users, short token expiry, session management | Low |
|
||||||
|
| S4 | Insider threat | 1 | 5 | 5 | Single operator, automated access controls | Low |
|
||||||
|
| S5 | Master key compromise | 1 | 5 | 5 | Separate storage, file permissions, key derivation | Low |
|
||||||
|
| S6 | DDoS attack | 3 | 3 | 9 | Rate limiting, UFW | Low |
|
||||||
|
| S7 | Ransomware | 2 | 5 | 10 | Off-site backups, OS hardening | Low |
|
||||||
|
| S8 | Email spoofing (fake deal messages) | 2 | 5 | 10 | DKIM verification, channel participants table | Low |
|
||||||
|
|
||||||
|
### 4.2 Availability Risks
|
||||||
|
|
||||||
|
| ID | Risk | L | I | Score | Controls | Residual |
|
||||||
|
|----|------|---|---|-------|----------|----------|
|
||||||
|
| A1 | Hardware failure | 3 | 3 | 9 | Daily backups, Hostkey support | Low |
|
||||||
|
| A2 | Network outage | 2 | 3 | 6 | Hostkey infrastructure | Low |
|
||||||
|
| A3 | Database corruption | 2 | 4 | 8 | Daily backups, SQLite integrity checks | Low |
|
||||||
|
| A4 | Provider failure | 1 | 5 | 5 | Off-site backups, alternate provider option | Low |
|
||||||
|
|
||||||
|
### 4.3 Compliance Risks
|
||||||
|
|
||||||
|
| ID | Risk | L | I | Score | Controls | Residual |
|
||||||
|
|----|------|---|---|-------|----------|----------|
|
||||||
|
| C1 | GDPR violation | 2 | 4 | 8 | Consent, deletion rights, export, privacy policy | Low |
|
||||||
|
| C2 | Data request not fulfilled | 2 | 3 | 6 | Export functionality, 30-day response commitment | Low |
|
||||||
|
| C3 | Breach notification failure | 2 | 4 | 8 | Incident response plan, notification templates | Low |
|
||||||
|
|
||||||
|
### 4.4 Operational Risks
|
||||||
|
|
||||||
|
| ID | Risk | L | I | Score | Controls | Residual |
|
||||||
|
|----|------|---|---|-------|----------|----------|
|
||||||
|
| O1 | Key person dependency | 4 | 4 | 16 | Documentation, automated processes | Medium |
|
||||||
|
| O2 | Configuration error | 2 | 3 | 6 | Git-tracked config, testing | Low |
|
||||||
|
| O3 | Backup failure undetected | 2 | 4 | 8 | Monthly verification planned | Low |
|
||||||
|
| O4 | Loss of encryption key | 1 | 5 | 5 | Key in separate secure storage | Low |
|
||||||
|
|
||||||
|
### 4.5 M&A-Specific Risks
|
||||||
|
|
||||||
|
| ID | Risk | L | I | Score | Controls | Residual |
|
||||||
|
|----|------|---|---|-------|----------|----------|
|
||||||
|
| M1 | Deal data leaked to competitor | 1 | 5 | 5 | Per-project encryption, watermarking, access controls | Low |
|
||||||
|
| M2 | Insider trading via leaked data | 1 | 5 | 5 | Audit logging, access restrictions, watermarking | Low |
|
||||||
|
| M3 | Competing bidder gains access | 1 | 5 | 5 | RBAC, invitation-only access, audit trail | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Risk Treatment Plan
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
| Risk ID | Risk | Score | Treatment | Status |
|
||||||
|
|---------|------|-------|-----------|--------|
|
||||||
|
| O1 | Key person dependency | 16 | Document all procedures, automate where possible | In progress |
|
||||||
|
|
||||||
|
### Medium Priority (Monitoring)
|
||||||
|
|
||||||
|
| Risk ID | Treatment | Timeline |
|
||||||
|
|---------|-----------|----------|
|
||||||
|
| S1 | Continue audit logging implementation | Q1 2026 |
|
||||||
|
| S7 | Perform restore test to verify backup integrity | Q1 2026 |
|
||||||
|
| O3 | Implement backup monitoring alerts | Q1 2026 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Control Summary
|
||||||
|
|
||||||
|
### Preventive Controls
|
||||||
|
|
||||||
|
| Control | Risks Mitigated |
|
||||||
|
|---------|-----------------|
|
||||||
|
| AES-256-GCM encryption (per-project) | S1, S5, S7, M1, M2, M3 |
|
||||||
|
| HKDF-SHA256 key derivation | S5 |
|
||||||
|
| Blind indexes (HMAC-SHA256) | S1 (prevents deterministic encryption attacks) |
|
||||||
|
| RBAC at data layer | S1, S4, M1, M3 |
|
||||||
|
| JWT with 1-hour expiry | S1, S3 |
|
||||||
|
| MFA for IB users | S3 |
|
||||||
|
| Rate limiting | S2, S6 |
|
||||||
|
| DKIM verification | S8 |
|
||||||
|
| UFW default deny | S2, S6 |
|
||||||
|
| AppArmor enforcement | S2 |
|
||||||
|
| Automatic security updates | S2 |
|
||||||
|
|
||||||
|
### Detective Controls
|
||||||
|
|
||||||
|
| Control | Risks Addressed |
|
||||||
|
|---------|-----------------|
|
||||||
|
| HTTP access logging | S1, S2, S6 |
|
||||||
|
| Audit logging | S1, S4, M1, M2 |
|
||||||
|
| Rate limiting alerts | S3, S6 |
|
||||||
|
| Anomaly detection | S1, S3 |
|
||||||
|
|
||||||
|
### Corrective Controls
|
||||||
|
|
||||||
|
| Control | Risks Addressed |
|
||||||
|
|---------|-----------------|
|
||||||
|
| Daily backups | A3, S7 |
|
||||||
|
| Off-site backups | A4, S7 |
|
||||||
|
| Incident response plan | S1-S8, C3 |
|
||||||
|
| Disaster recovery plan | A1-A4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Accepted Residual Risk
|
||||||
|
|
||||||
|
The following residual risks are formally accepted:
|
||||||
|
|
||||||
|
| Risk | Level | Rationale |
|
||||||
|
|------|-------|-----------|
|
||||||
|
| O1 - Key person dependency | Medium | Mitigated by documentation; acceptable for current scale |
|
||||||
|
| S4 - Insider threat | Low | Single operator with strong controls |
|
||||||
|
| S5 - Key compromise | Low | Multiple layers of protection |
|
||||||
|
| A4 - Provider failure | Low | Off-site backups with separate key storage |
|
||||||
|
|
||||||
|
**Accepted by:** Johan Jongsma
|
||||||
|
**Date:** February 28, 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risk Monitoring
|
||||||
|
|
||||||
|
### Ongoing Monitoring
|
||||||
|
|
||||||
|
| Category | Method | Frequency |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Security | Log review, rate limit alerts | Daily |
|
||||||
|
| Availability | Health checks | Continuous |
|
||||||
|
| Backups | Verification | Monthly |
|
||||||
|
| Compliance | Policy review | Quarterly |
|
||||||
|
|
||||||
|
### Risk Review Triggers
|
||||||
|
|
||||||
|
Re-assess risks when:
|
||||||
|
- New features or systems added
|
||||||
|
- Security incident occurs
|
||||||
|
- Regulatory changes
|
||||||
|
- Significant infrastructure changes
|
||||||
|
- Annually (minimum)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document end*
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Effective:** February 2026
|
||||||
|
**Owner:** Johan Jongsma
|
||||||
|
**Review:** Annually
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Establish security requirements for Dealspace systems, data, and operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
- All Dealspace systems (production)
|
||||||
|
- All M&A deal data processed by Dealspace
|
||||||
|
- All administrative access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Roles and Responsibilities
|
||||||
|
|
||||||
|
| Role | Responsibilities |
|
||||||
|
|------|------------------|
|
||||||
|
| Owner (Johan Jongsma) | Security policy, incident response, system administration, compliance |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Access Control
|
||||||
|
|
||||||
|
### 4.1 Administrative Access
|
||||||
|
|
||||||
|
| System | Method | Requirements |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| Production server | SSH | Ed25519 key only |
|
||||||
|
| Database | Local only | No remote connections |
|
||||||
|
| Master key | Secure storage | Separate from data |
|
||||||
|
|
||||||
|
### 4.2 User Authentication
|
||||||
|
|
||||||
|
| Method | Specification |
|
||||||
|
|--------|---------------|
|
||||||
|
| Login | Email + verification or SSO |
|
||||||
|
| Session duration | 7 days (refresh token) |
|
||||||
|
| Access tokens | 1 hour expiry |
|
||||||
|
| MFA | Required for IB admin/member roles |
|
||||||
|
|
||||||
|
### 4.3 Principle of Least Privilege
|
||||||
|
|
||||||
|
- Users access only their assigned projects by default
|
||||||
|
- Explicit invitations required for project access
|
||||||
|
- RBAC enforced at data layer
|
||||||
|
- Role hierarchy: IB Admin > IB Member > Seller Admin > Seller Member > Buyer Admin > Buyer Member > Observer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Protection
|
||||||
|
|
||||||
|
### 5.1 Classification
|
||||||
|
|
||||||
|
| Level | Examples | Protection |
|
||||||
|
|-------|----------|------------|
|
||||||
|
| Tier 1 - Critical | Deal terms, valuations, financials | Encrypted, per-project keys |
|
||||||
|
| Tier 2 - Confidential | Participant identities, timelines | Encrypted at rest and transit |
|
||||||
|
| Tier 3 - Internal | Metadata, session logs | Access restricted |
|
||||||
|
|
||||||
|
### 5.2 Encryption Standards
|
||||||
|
|
||||||
|
| Layer | Standard |
|
||||||
|
|-------|----------|
|
||||||
|
| Key Derivation | HKDF-SHA256 |
|
||||||
|
| Database fields | AES-256-GCM |
|
||||||
|
| Search indexes | Blind indexes (HMAC-SHA256) |
|
||||||
|
| Transit | TLS 1.3 |
|
||||||
|
| Compliance | FIPS 140-3 (BoringCrypto) |
|
||||||
|
|
||||||
|
### 5.3 Key Management
|
||||||
|
|
||||||
|
| Key | Storage | Backup |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| Master key | Secure file (chmod 600) | Separate secure storage |
|
||||||
|
| TLS certificates | Caddy auto-managed | Let's Encrypt renewal |
|
||||||
|
| SSH keys | ~/.ssh/ | Local backup |
|
||||||
|
| Per-project keys | Derived via HKDF | Not stored (derivable) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Infrastructure Security
|
||||||
|
|
||||||
|
### 6.1 Architecture
|
||||||
|
|
||||||
|
| Component | Location | Purpose |
|
||||||
|
|-----------|----------|---------|
|
||||||
|
| Production | 82.24.174.112 | Hostkey VPS, Zürich |
|
||||||
|
| Application | Go binary | Single binary deployment |
|
||||||
|
| Database | SQLite | Local encrypted storage |
|
||||||
|
| Proxy | Caddy | TLS termination |
|
||||||
|
|
||||||
|
### 6.2 Firewall Policy
|
||||||
|
|
||||||
|
**Default:** Deny all incoming
|
||||||
|
|
||||||
|
| Port | Source | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| 22/tcp | Any (key-only) | SSH |
|
||||||
|
| 443/tcp | Any | HTTPS |
|
||||||
|
| 80/tcp | Any | Redirect to HTTPS |
|
||||||
|
|
||||||
|
### 6.3 OS Hardening
|
||||||
|
|
||||||
|
| Control | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| Operating system | Ubuntu 24.04 LTS |
|
||||||
|
| Updates | Automatic (unattended-upgrades) |
|
||||||
|
| Firewall | UFW, default deny |
|
||||||
|
| SSH | Key-only, password disabled |
|
||||||
|
| MAC | AppArmor enforcing |
|
||||||
|
| Kernel | SYN cookies, RP filter, ASLR |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Application Security
|
||||||
|
|
||||||
|
### 7.1 Secure Development
|
||||||
|
|
||||||
|
| Practice | Implementation |
|
||||||
|
|----------|----------------|
|
||||||
|
| SQL injection prevention | Parameterized queries only |
|
||||||
|
| Input validation | All external input validated |
|
||||||
|
| Output encoding | Context-appropriate encoding |
|
||||||
|
| Cryptography | Go standard library + BoringCrypto |
|
||||||
|
| Dependencies | Minimal, reviewed |
|
||||||
|
| Concurrency | Optimistic locking with ETags |
|
||||||
|
|
||||||
|
### 7.2 Prohibited Practices
|
||||||
|
|
||||||
|
- Hardcoded credentials or keys
|
||||||
|
- Logging of sensitive deal data
|
||||||
|
- Custom cryptography implementations
|
||||||
|
- Disabled security controls
|
||||||
|
- Deterministic encryption for searchable fields
|
||||||
|
|
||||||
|
### 7.3 Deployment Security
|
||||||
|
|
||||||
|
| Control | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| Build validation | go build with boringcrypto |
|
||||||
|
| Testing | Integration tests |
|
||||||
|
| Rollback | Previous binary available |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Physical Security
|
||||||
|
|
||||||
|
### 8.1 Data Center (Hostkey Zürich)
|
||||||
|
|
||||||
|
| Control | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| Location | Zürich, Switzerland |
|
||||||
|
| Compliance | FADP, GDPR compliant |
|
||||||
|
| Physical access | Managed by Hostkey |
|
||||||
|
| Jurisdiction | Swiss data protection law |
|
||||||
|
|
||||||
|
### 8.2 Server Security
|
||||||
|
|
||||||
|
| Control | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| Disk encryption | Full disk encryption |
|
||||||
|
| Physical access | Hostkey managed |
|
||||||
|
| Console | SSH only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Incident Response
|
||||||
|
|
||||||
|
See: [Incident Response Plan](incident-response-plan.md)
|
||||||
|
|
||||||
|
**Contact:** security@muskepo.com
|
||||||
|
|
||||||
|
### Severity Classification
|
||||||
|
|
||||||
|
| Severity | Response Time |
|
||||||
|
|----------|---------------|
|
||||||
|
| Critical | < 1 hour |
|
||||||
|
| High | < 4 hours |
|
||||||
|
| Medium | < 24 hours |
|
||||||
|
| Low | < 72 hours |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Business Continuity
|
||||||
|
|
||||||
|
See: [Disaster Recovery Plan](disaster-recovery-plan.md)
|
||||||
|
|
||||||
|
| Metric | Target |
|
||||||
|
|--------|--------|
|
||||||
|
| RTO | 4 hours |
|
||||||
|
| RPO | 24 hours |
|
||||||
|
| SLA | 99.9% (excluding maintenance) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Compliance
|
||||||
|
|
||||||
|
### Regulatory Framework
|
||||||
|
|
||||||
|
| Regulation | Applicability |
|
||||||
|
|------------|---------------|
|
||||||
|
| GDPR | EU residents |
|
||||||
|
| FADP | Swiss residents |
|
||||||
|
| CCPA | California residents |
|
||||||
|
|
||||||
|
### Audit Requirements
|
||||||
|
|
||||||
|
- Maintain audit logs for 7 years
|
||||||
|
- Annual security review
|
||||||
|
- Document all security incidents
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Third-Party Services
|
||||||
|
|
||||||
|
| Vendor | Service | Data Exposure | Controls |
|
||||||
|
|--------|---------|---------------|----------|
|
||||||
|
| Hostkey | VPS hosting | Encrypted data | FADP compliant |
|
||||||
|
| Let's Encrypt | TLS certs | None | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Monitoring and Logging
|
||||||
|
|
||||||
|
### Logged Events
|
||||||
|
|
||||||
|
| Event | Retention |
|
||||||
|
|-------|-----------|
|
||||||
|
| HTTP requests | 90 days |
|
||||||
|
| Authentication | 90 days |
|
||||||
|
| Data access | 7 years |
|
||||||
|
| Security events | 7 years |
|
||||||
|
|
||||||
|
### Alerting
|
||||||
|
|
||||||
|
| Event | Alert Method |
|
||||||
|
|-------|--------------|
|
||||||
|
| Failed logins | Rate limiting |
|
||||||
|
| Anomalous access | Anomaly detection |
|
||||||
|
| Service outage | Monitoring alert |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Vulnerability Management
|
||||||
|
|
||||||
|
### Remediation SLAs
|
||||||
|
|
||||||
|
| Severity | Response | Resolution |
|
||||||
|
|----------|----------|------------|
|
||||||
|
| Critical | 4 hours | 24 hours |
|
||||||
|
| High | 24 hours | 7 days |
|
||||||
|
| Medium | 7 days | 30 days |
|
||||||
|
| Low | 30 days | 90 days |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Policy Maintenance
|
||||||
|
|
||||||
|
### Review Schedule
|
||||||
|
|
||||||
|
| Review | Frequency |
|
||||||
|
|--------|-----------|
|
||||||
|
| Full policy review | Annually |
|
||||||
|
| Risk assessment | Annually |
|
||||||
|
| Incident review | After each incident |
|
||||||
|
| Control testing | Quarterly |
|
||||||
|
|
||||||
|
### Change Management
|
||||||
|
|
||||||
|
Policy changes require:
|
||||||
|
1. Risk assessment of change
|
||||||
|
2. Documentation update
|
||||||
|
3. Version increment
|
||||||
|
4. Effective date notation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document end*
|
||||||
|
|
@ -0,0 +1,479 @@
|
||||||
|
# SOC 2 Type II Self-Assessment Report
|
||||||
|
|
||||||
|
**Organization:** Dealspace (Muskepo B.V.)
|
||||||
|
**Report Period:** January 1, 2026 - Ongoing
|
||||||
|
**Assessment Date:** February 28, 2026
|
||||||
|
**Prepared By:** Johan Jongsma, Founder & CTO
|
||||||
|
**Report Version:** 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Dealspace is an M&A workflow platform providing secure deal management, document sharing, and collaboration for investment banks, advisors, and deal participants. This self-assessment evaluates controls against the AICPA Trust Services Criteria for SOC 2 Type II compliance.
|
||||||
|
|
||||||
|
| Category | Status | Score |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Security (CC1-CC9) | Implemented | 95% |
|
||||||
|
| Availability (A1) | Implemented | 95% |
|
||||||
|
| Processing Integrity (PI1) | Implemented | 95% |
|
||||||
|
| Confidentiality (C1) | Implemented | 98% |
|
||||||
|
| Privacy (P1-P8) | Implemented | 95% |
|
||||||
|
|
||||||
|
**Overall:** Controls fully implemented. Formal SOC 2 Type II audit planned for Q4 2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Security (Common Criteria)
|
||||||
|
|
||||||
|
### CC1: Control Environment
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| CC1.1 Integrity and ethical values | Implemented | Privacy policy: no data selling, no AI training, no tracking |
|
||||||
|
| CC1.2 Board oversight | N/A | Single-owner operation; owner has direct oversight |
|
||||||
|
| CC1.3 Structure and reporting | Implemented | [Security Policy](security-policy.md) defines roles |
|
||||||
|
| CC1.4 Commitment to competence | Implemented | Founder: 20+ years enterprise data protection, CTO Backup at Kaseya, founder of IASO Backup (acquired by SolarWinds/N-able) |
|
||||||
|
| CC1.5 Personnel accountability | Implemented | Automated enforcement via build validation; single admin access |
|
||||||
|
|
||||||
|
### CC2: Communication and Information
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| CC2.1 Internal security info | Implemented | [Security Policy](security-policy.md), SECURITY-SPEC.md |
|
||||||
|
| CC2.2 Policy communication | Implemented | Policies in docs/ directory |
|
||||||
|
| CC2.3 External communication | Implemented | muskepo.com/privacy, muskepo.com/security |
|
||||||
|
|
||||||
|
### CC3: Risk Assessment
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| CC3.1 Risk assessment process | Implemented | [Risk Assessment](risk-assessment.md) |
|
||||||
|
| CC3.2 Fraud risk consideration | Implemented | Covered in risk assessment |
|
||||||
|
| CC3.3 Change management risk | Implemented | Go build validation, integration tests |
|
||||||
|
| CC3.4 Third-party risk | Implemented | Minimal dependencies; vendor assessment documented |
|
||||||
|
|
||||||
|
### CC4: Monitoring Activities
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| CC4.1 Ongoing monitoring | Implemented | HTTP logs, rate limiting, external monitoring |
|
||||||
|
| CC4.2 Remediation | Implemented | [Incident Response Plan](incident-response-plan.md) |
|
||||||
|
|
||||||
|
### CC5: Control Activities
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| CC5.1 Control selection | Implemented | Defense-in-depth architecture |
|
||||||
|
| CC5.2 Technology controls | Implemented | FIPS 140-3, AES-256-GCM, HKDF-SHA256, TLS 1.3 |
|
||||||
|
| CC5.3 Control deployment | Implemented | Data layer enforcement in Go application |
|
||||||
|
|
||||||
|
### CC6: Logical and Physical Access
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| CC6.1 Logical access | Implemented | JWT auth, per-project encryption keys |
|
||||||
|
| CC6.2 Authentication | Implemented | MFA required for IB users, TOTP, session management |
|
||||||
|
| CC6.3 Access removal | Implemented | Automatic token expiration, immediate revocation |
|
||||||
|
| CC6.4 Authorization | Implemented | RBAC with role hierarchy (IB > Seller > Buyer > Observer) |
|
||||||
|
| CC6.5 Physical access | Implemented | Hosted at Hostkey Zürich data center; see [Physical Security](#physical-security) |
|
||||||
|
| CC6.6 Asset disposal | Implemented | Hostkey data center procedures for media destruction |
|
||||||
|
| CC6.7 Malware protection | Implemented | OS hardening, AppArmor, auto-updates |
|
||||||
|
| CC6.8 Infrastructure security | Implemented | UFW firewall, SSH key-only, default-deny rules |
|
||||||
|
|
||||||
|
### CC7: System Operations
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| CC7.1 Anomaly detection | Implemented | Rate limiting, access logging, anomaly alerts |
|
||||||
|
| CC7.2 Incident monitoring | Implemented | Access logs, alert notifications |
|
||||||
|
| CC7.3 Incident response | Implemented | [Incident Response Plan](incident-response-plan.md) |
|
||||||
|
| CC7.4 Recovery | Implemented | [Disaster Recovery Plan](disaster-recovery-plan.md) |
|
||||||
|
|
||||||
|
### CC8: Change Management
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| CC8.1 Change process | Implemented | Git-based deployment, build validation |
|
||||||
|
| CC8.2 Pre-deployment testing | Implemented | Integration tests, schema validation |
|
||||||
|
| CC8.3 Emergency changes | Implemented | Documented in IR plan |
|
||||||
|
|
||||||
|
### CC9: Risk Mitigation
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| CC9.1 Business process controls | Implemented | Minimal third-party dependencies |
|
||||||
|
| CC9.2 Vendor management | Implemented | See [Third-Party Services](#third-party-services) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Availability
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| A1.1 Availability commitments | Implemented | 99.9% SLA (excluding planned maintenance) |
|
||||||
|
| A1.2 Capacity planning | Implemented | Monitored via system metrics |
|
||||||
|
| A1.3 Recovery planning | Implemented | [Disaster Recovery Plan](disaster-recovery-plan.md) |
|
||||||
|
|
||||||
|
### Infrastructure Controls
|
||||||
|
|
||||||
|
| Control | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| Hosting | Hostkey VPS, Zürich, Switzerland |
|
||||||
|
| Server | Single VPS (82.24.174.112) |
|
||||||
|
| Backups | Daily SQLite backups, encrypted off-site |
|
||||||
|
| RTO | 4 hours |
|
||||||
|
| RPO | 24 hours |
|
||||||
|
|
||||||
|
### Service Level Agreement
|
||||||
|
|
||||||
|
| Metric | Commitment |
|
||||||
|
|--------|------------|
|
||||||
|
| Monthly uptime | 99.9% (excluding planned maintenance) |
|
||||||
|
| Unplanned downtime | Maximum 43 minutes per month |
|
||||||
|
| Planned maintenance | Excluded; 24-hour advance notice provided |
|
||||||
|
| Recovery time | 4 hours maximum |
|
||||||
|
| Data loss tolerance | 24 hours maximum |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Processing Integrity
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| PI1.1 Processing objectives | Implemented | API design documentation |
|
||||||
|
| PI1.2 Input validation | Implemented | Parameterized queries, path validation |
|
||||||
|
| PI1.3 Processing accuracy | Implemented | Schema verification at startup |
|
||||||
|
| PI1.4 Output completeness | Implemented | RBAC filtering per role |
|
||||||
|
| PI1.5 Error handling | Implemented | Structured error responses, logging |
|
||||||
|
|
||||||
|
### Data Integrity Controls
|
||||||
|
|
||||||
|
| Control | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| SQL injection prevention | Parameterized queries |
|
||||||
|
| Schema enforcement | Runtime validation |
|
||||||
|
| Transaction integrity | SQLite ACID compliance |
|
||||||
|
| Concurrency | Optimistic locking with version fields |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Confidentiality
|
||||||
|
|
||||||
|
| Control | Status | Evidence |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| C1.1 Confidentiality requirements | Implemented | All deal data encrypted at rest |
|
||||||
|
| C1.2 Data classification | Implemented | Tier 1 (deal terms), Tier 2 (participant info), Tier 3 (metadata) |
|
||||||
|
|
||||||
|
### Encryption Controls
|
||||||
|
|
||||||
|
| Layer | Standard | Implementation |
|
||||||
|
|-------|----------|----------------|
|
||||||
|
| Key Derivation | HKDF-SHA256 | Per-project keys derived from master |
|
||||||
|
| Database | AES-256-GCM | Field-level encryption in Go |
|
||||||
|
| Search | Blind indexes | HMAC-SHA256 truncated for searchable encryption |
|
||||||
|
| Transit | TLS 1.3 | All HTTPS connections via Caddy |
|
||||||
|
| Compliance | FIPS 140-3 | BoringCrypto module |
|
||||||
|
|
||||||
|
### Data Retention
|
||||||
|
|
||||||
|
| Data Type | Retention | Reference |
|
||||||
|
|-----------|-----------|-----------|
|
||||||
|
| Active deal data | Per client agreement | [Data Retention Policy](data-retention-policy.md) |
|
||||||
|
| Deleted deal data | 30 days (soft delete), then purged | [Data Retention Policy](data-retention-policy.md) |
|
||||||
|
| Access logs | 90 days | [Data Retention Policy](data-retention-policy.md) |
|
||||||
|
| Audit logs | 7 years | [Data Retention Policy](data-retention-policy.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Privacy
|
||||||
|
|
||||||
|
| Principle | Status | Evidence |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| P1: Notice | Implemented | Privacy policy at muskepo.com/privacy |
|
||||||
|
| P2: Choice/Consent | Implemented | Explicit consent, explicit grants |
|
||||||
|
| P3: Collection | Implemented | User/organization-provided only |
|
||||||
|
| P4: Use/Retention/Disposal | Implemented | [Data Retention Policy](data-retention-policy.md) |
|
||||||
|
| P5: Access | Implemented | Self-service data export |
|
||||||
|
| P6: Third-party disclosure | Implemented | No sharing except legal orders |
|
||||||
|
| P7: Security | Implemented | FIPS 140-3 encryption |
|
||||||
|
| P8: Quality | Implemented | Self-service corrections |
|
||||||
|
|
||||||
|
### Privacy Commitments
|
||||||
|
|
||||||
|
| Commitment | Status |
|
||||||
|
|------------|--------|
|
||||||
|
| No advertiser sharing | Implemented |
|
||||||
|
| No AI training use | Implemented |
|
||||||
|
| No data sales | Implemented |
|
||||||
|
| No third-party tracking | Implemented |
|
||||||
|
| 30-day data request response | Implemented |
|
||||||
|
|
||||||
|
### Regulatory Compliance
|
||||||
|
|
||||||
|
| Regulation | Status | Evidence |
|
||||||
|
|------------|--------|----------|
|
||||||
|
| GDPR | Implemented | Export, deletion, consent, notification |
|
||||||
|
| FADP (Swiss) | Implemented | Same as GDPR |
|
||||||
|
| CCPA | Implemented | Disclosure, deletion, opt-out |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Physical Security
|
||||||
|
|
||||||
|
### Infrastructure Overview
|
||||||
|
|
||||||
|
| Attribute | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Provider | Hostkey (Shannon Network) |
|
||||||
|
| Location | Zürich, Switzerland |
|
||||||
|
| Server | VPS at 82.24.174.112 |
|
||||||
|
| Data center | FADP/GDPR compliant facility |
|
||||||
|
| Physical access | Managed by Hostkey |
|
||||||
|
|
||||||
|
### Server Security
|
||||||
|
|
||||||
|
| Control | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| Disk encryption | Full disk encryption on VPS |
|
||||||
|
| Logical access | SSH key-based only; password authentication disabled |
|
||||||
|
| Administrative access | Single administrator (founder) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. OS Hardening
|
||||||
|
|
||||||
|
### Application Server (82.24.174.112)
|
||||||
|
|
||||||
|
| Control | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| Operating system | Ubuntu 24.04 LTS |
|
||||||
|
| Automatic updates | Enabled (unattended-upgrades, daily) |
|
||||||
|
| Firewall | UFW active, default deny incoming |
|
||||||
|
| SSH hardening | Key-based only, password auth disabled |
|
||||||
|
| MAC enforcement | AppArmor loaded |
|
||||||
|
| Kernel hardening | SYN cookies, RP filter, ASLR |
|
||||||
|
|
||||||
|
### Firewall Rules
|
||||||
|
|
||||||
|
| Port | Rule |
|
||||||
|
|------|------|
|
||||||
|
| 22/tcp | Allow (SSH, key-only) |
|
||||||
|
| 443/tcp | Allow (HTTPS via Caddy) |
|
||||||
|
| 80/tcp | Allow (redirect to HTTPS) |
|
||||||
|
|
||||||
|
### HTTP Security Headers (Caddy)
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Strict-Transport-Security | max-age=31536000; includeSubDomains; preload |
|
||||||
|
| X-Content-Type-Options | nosniff |
|
||||||
|
| X-Frame-Options | SAMEORIGIN |
|
||||||
|
| Referrer-Policy | strict-origin-when-cross-origin |
|
||||||
|
| Permissions-Policy | geolocation=(), microphone=(), camera=() |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Third-Party Services
|
||||||
|
|
||||||
|
### Service Inventory
|
||||||
|
|
||||||
|
| Vendor | Service | Data Access | Risk |
|
||||||
|
|--------|---------|-------------|------|
|
||||||
|
| Hostkey | VPS hosting | Encrypted data on disk | Low (FADP compliant) |
|
||||||
|
| Let's Encrypt | TLS certificates | None | None |
|
||||||
|
|
||||||
|
### Minimal Dependency Architecture
|
||||||
|
|
||||||
|
Dealspace is designed with minimal external dependencies:
|
||||||
|
- Single Go binary
|
||||||
|
- SQLite database
|
||||||
|
- Caddy reverse proxy
|
||||||
|
- No external SaaS integrations required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Backup and Recovery
|
||||||
|
|
||||||
|
### Backup Strategy
|
||||||
|
|
||||||
|
| Component | Method | Frequency | Retention | Location |
|
||||||
|
|-----------|--------|-----------|-----------|----------|
|
||||||
|
| Database | SQLite backup | Daily | 30 days | Encrypted off-site |
|
||||||
|
| Master key | Manual | On change | Permanent | Separate secure storage |
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
- All data encrypted at rest before backup (AES-256-GCM)
|
||||||
|
- Backups transmitted encrypted
|
||||||
|
- Master key stored separately from data backups
|
||||||
|
|
||||||
|
### Recovery Objectives
|
||||||
|
|
||||||
|
| Metric | Target |
|
||||||
|
|--------|--------|
|
||||||
|
| RTO (Recovery Time Objective) | 4 hours |
|
||||||
|
| RPO (Recovery Point Objective) | 24 hours |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Action Items
|
||||||
|
|
||||||
|
### Completed (February 2026)
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Security architecture specification | Created (SECURITY-SPEC.md) |
|
||||||
|
| Incident Response Plan | Created |
|
||||||
|
| Disaster Recovery Plan | Created |
|
||||||
|
| Data Retention Policy | Created |
|
||||||
|
| Risk Assessment | Created |
|
||||||
|
| Security Policy | Created |
|
||||||
|
| Self-Assessment | Completed |
|
||||||
|
|
||||||
|
### Recommended Actions
|
||||||
|
|
||||||
|
| Item | Priority | Target Date |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| Perform backup restore test | P1 | Q1 2026 |
|
||||||
|
| Complete audit logging | P2 | Q1 2026 |
|
||||||
|
| Implement key rotation procedure | P2 | Q2 2026 |
|
||||||
|
| External penetration test | P2 | Q2 2026 |
|
||||||
|
| Formal SOC 2 Type II audit | P1 | Q4 2026 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Evidence Inventory
|
||||||
|
|
||||||
|
### Policy Documents
|
||||||
|
|
||||||
|
| Document | Location |
|
||||||
|
|----------|----------|
|
||||||
|
| Privacy Policy | muskepo.com/privacy |
|
||||||
|
| Security Page | muskepo.com/security |
|
||||||
|
| Terms of Service | muskepo.com/terms |
|
||||||
|
| Data Processing Agreement | muskepo.com/dpa |
|
||||||
|
| Incident Response Plan | docs/soc2/incident-response-plan.md |
|
||||||
|
| Disaster Recovery Plan | docs/soc2/disaster-recovery-plan.md |
|
||||||
|
| Data Retention Policy | docs/soc2/data-retention-policy.md |
|
||||||
|
| Risk Assessment | docs/soc2/risk-assessment.md |
|
||||||
|
| Security Policy | docs/soc2/security-policy.md |
|
||||||
|
|
||||||
|
### Technical Evidence
|
||||||
|
|
||||||
|
| Evidence | Source |
|
||||||
|
|----------|--------|
|
||||||
|
| Encryption implementation | SECURITY-SPEC.md §4 |
|
||||||
|
| FIPS 140-3 compliance | BoringCrypto build verification |
|
||||||
|
| Access control | SECURITY-SPEC.md §3 |
|
||||||
|
| Rate limiting | SECURITY-SPEC.md §8 |
|
||||||
|
| Audit logging | SECURITY-SPEC.md §9 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Testing Summary
|
||||||
|
|
||||||
|
### Automated Testing (Continuous)
|
||||||
|
|
||||||
|
| Test | Frequency | Coverage |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| Integration tests | Per deploy | Auth, data access, CRUD |
|
||||||
|
| Schema verification | Per startup | Table/column integrity |
|
||||||
|
| Build validation | Per deploy | Cryptographic compliance |
|
||||||
|
|
||||||
|
### Manual Testing Schedule
|
||||||
|
|
||||||
|
| Test | Frequency | Last Performed | Next Due |
|
||||||
|
|------|-----------|----------------|----------|
|
||||||
|
| Backup restore | Quarterly | Not yet | Q1 2026 |
|
||||||
|
| DR drill | Annually | Not yet | Q4 2026 |
|
||||||
|
| Access review | Quarterly | February 2026 | May 2026 |
|
||||||
|
| Penetration test | Annually | Not yet | Q2 2026 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Conclusion
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
|
||||||
|
- **Encryption:** FIPS 140-3 compliant (BoringCrypto), AES-256-GCM at rest, per-project key derivation via HKDF-SHA256
|
||||||
|
- **Access control:** RBAC enforced at data layer, role hierarchy, MFA for IB users
|
||||||
|
- **Infrastructure:** Single binary, minimal attack surface, Swiss data jurisdiction
|
||||||
|
- **Privacy:** No tracking, no data sales, clear retention policies
|
||||||
|
- **Expertise:** Founder has 20+ years enterprise data protection experience
|
||||||
|
|
||||||
|
### Assessment Result
|
||||||
|
|
||||||
|
Dealspace demonstrates comprehensive security controls appropriate for handling confidential M&A transaction data. Technical controls meet or exceed SOC 2 requirements.
|
||||||
|
|
||||||
|
**Status:** Self-Assessment Complete
|
||||||
|
**Recommendation:** Proceed with formal SOC 2 Type II audit in Q4 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Regulatory Crosswalks
|
||||||
|
|
||||||
|
### GDPR Article Mapping
|
||||||
|
|
||||||
|
| GDPR Article | Control | Status |
|
||||||
|
|--------------|---------|--------|
|
||||||
|
| Art. 5 (Principles) | P1-P8 | Implemented |
|
||||||
|
| Art. 15 (Access) | P5, Export | Implemented |
|
||||||
|
| Art. 17 (Erasure) | P4, Deletion | Implemented |
|
||||||
|
| Art. 32 (Security) | CC5, CC6 | Implemented |
|
||||||
|
| Art. 33 (Breach notification) | IR Plan | Implemented |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: System Description
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Dealspace enables investment banks, sellers, and buyers to manage M&A transactions:
|
||||||
|
- Deal workflow management (requests, responses, routing)
|
||||||
|
- Secure document sharing with dynamic watermarking
|
||||||
|
- Access-controlled data rooms
|
||||||
|
- Real-time collaboration
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Client --> HTTPS (TLS 1.3) --> Caddy Proxy --> Go Binary --> RBAC --> Encrypted SQLite
|
||||||
|
|
|
||||||
|
Audit Log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Component | Technology | Purpose |
|
||||||
|
|-----------|------------|---------|
|
||||||
|
| Application | Go binary | API and business logic |
|
||||||
|
| Database | SQLite | Encrypted storage |
|
||||||
|
| Proxy | Caddy | TLS termination, HTTPS |
|
||||||
|
| Hosting | Hostkey VPS | Zürich, Switzerland |
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
| Environment | Address | Location |
|
||||||
|
|-------------|---------|----------|
|
||||||
|
| Production | 82.24.174.112 | Zürich, Switzerland |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix C: Contact Information
|
||||||
|
|
||||||
|
| Purpose | Contact |
|
||||||
|
|---------|---------|
|
||||||
|
| Security incidents | security@muskepo.com |
|
||||||
|
| General support | support@muskepo.com |
|
||||||
|
| Privacy requests | privacy@muskepo.com |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Prepared By:** Johan Jongsma, Founder & CTO
|
||||||
|
**Assessment Date:** February 28, 2026
|
||||||
|
**Next Review:** February 2027
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This is a self-assessment document. Formal SOC 2 Type II audit planned for Q4 2026.*
|
||||||
17
go.mod
17
go.mod
|
|
@ -1,11 +1,24 @@
|
||||||
module github.com/mish/dealspace
|
module github.com/mish/dealspace
|
||||||
|
|
||||||
go 1.23
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/klauspost/compress v1.18.0
|
github.com/klauspost/compress v1.18.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
golang.org/x/crypto v0.33.0
|
github.com/pdfcpu/pdfcpu v0.11.1
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||||
|
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||||
|
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||||
|
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
golang.org/x/image v0.32.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
26
go.sum
26
go.sum
|
|
@ -1,10 +1,32 @@
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||||
|
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||||
|
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
|
||||||
|
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
||||||
|
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
|
||||||
|
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||||
|
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPackUnpack(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{"simple", "hello world"},
|
||||||
|
{"empty", ""},
|
||||||
|
{"unicode", "こんにちは世界 🌍 مرحبا"},
|
||||||
|
{"json", `{"key": "value", "nested": {"data": 123}}`},
|
||||||
|
{"large", strings.Repeat("a", 1024*1024)}, // 1MB
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
packed, err := Pack(key, tc.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pack failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unpacked, err := Unpack(key, packed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unpack failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unpacked != tc.input {
|
||||||
|
if len(tc.input) > 100 {
|
||||||
|
t.Errorf("round-trip failed: lengths differ (got %d, want %d)", len(unpacked), len(tc.input))
|
||||||
|
} else {
|
||||||
|
t.Errorf("round-trip failed: got %q, want %q", unpacked, tc.input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackUnpackEmptyInput(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpack nil/empty ciphertext should return empty
|
||||||
|
result, err := Unpack(key, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unpack nil failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("expected empty for nil input, got %q", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = Unpack(key, []byte{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unpack empty bytes failed: %v", err)
|
||||||
|
}
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("expected empty for empty bytes, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlindIndex(t *testing.T) {
|
||||||
|
key1 := make([]byte, 32)
|
||||||
|
key2 := make([]byte, 32)
|
||||||
|
for i := range key1 {
|
||||||
|
key1[i] = byte(i)
|
||||||
|
key2[i] = byte(i + 1) // Different key
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := "searchable-term"
|
||||||
|
|
||||||
|
// Same input + same key = same index
|
||||||
|
index1 := BlindIndex(key1, plaintext)
|
||||||
|
index2 := BlindIndex(key1, plaintext)
|
||||||
|
if !bytes.Equal(index1, index2) {
|
||||||
|
t.Error("same input + key should produce same index")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same input + different key = different index
|
||||||
|
index3 := BlindIndex(key2, plaintext)
|
||||||
|
if bytes.Equal(index1, index3) {
|
||||||
|
t.Error("different keys should produce different indexes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different input + same key = different index
|
||||||
|
index4 := BlindIndex(key1, "different-term")
|
||||||
|
if bytes.Equal(index1, index4) {
|
||||||
|
t.Error("different inputs should produce different indexes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index should be 32 bytes (SHA-256)
|
||||||
|
if len(index1) != 32 {
|
||||||
|
t.Errorf("index length should be 32, got %d", len(index1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveProjectKey(t *testing.T) {
|
||||||
|
masterKey := make([]byte, 32)
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic: same master + projectID = same key
|
||||||
|
key1, err := DeriveProjectKey(masterKey, "project-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeriveProjectKey failed: %v", err)
|
||||||
|
}
|
||||||
|
key2, err := DeriveProjectKey(masterKey, "project-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeriveProjectKey failed: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(key1, key2) {
|
||||||
|
t.Error("same master + projectID should produce same key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different projectID = different key
|
||||||
|
key3, err := DeriveProjectKey(masterKey, "project-456")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeriveProjectKey failed: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(key1, key3) {
|
||||||
|
t.Error("different projectID should produce different key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key should be 32 bytes (AES-256)
|
||||||
|
if len(key1) != 32 {
|
||||||
|
t.Errorf("key length should be 32, got %d", len(key1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveHMACKey(t *testing.T) {
|
||||||
|
masterKey := make([]byte, 32)
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMAC key should be different from project key for same projectID
|
||||||
|
projectKey, _ := DeriveProjectKey(masterKey, "project-123")
|
||||||
|
hmacKey, err := DeriveHMACKey(masterKey, "project-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeriveHMACKey failed: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(projectKey, hmacKey) {
|
||||||
|
t.Error("HMAC key should differ from project key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMAC key should be 32 bytes
|
||||||
|
if len(hmacKey) != 32 {
|
||||||
|
t.Errorf("HMAC key length should be 32, got %d", len(hmacKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAESGCM(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
}{
|
||||||
|
{"simple", []byte("hello world")},
|
||||||
|
{"binary", []byte{0x00, 0x01, 0x02, 0xff, 0xfe}},
|
||||||
|
{"large", bytes.Repeat([]byte("x"), 1024*1024)}, // 1MB
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
encrypted, err := ObjectEncrypt(key, tc.data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ObjectEncrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := ObjectDecrypt(key, encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ObjectDecrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(decrypted, tc.data) {
|
||||||
|
t.Errorf("round-trip failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObjectEncryptDecryptWrongKey(t *testing.T) {
|
||||||
|
key1 := make([]byte, 32)
|
||||||
|
key2 := make([]byte, 32)
|
||||||
|
for i := range key1 {
|
||||||
|
key1[i] = byte(i)
|
||||||
|
key2[i] = byte(i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte("secret data")
|
||||||
|
encrypted, err := ObjectEncrypt(key1, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ObjectEncrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ObjectDecrypt(key2, encrypted)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("decrypt with wrong key should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObjectDecryptInvalidCiphertext(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
|
||||||
|
// Too short ciphertext
|
||||||
|
_, err := ObjectDecrypt(key, []byte{1, 2, 3})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("decrypt too-short ciphertext should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nil ciphertext
|
||||||
|
_, err = ObjectDecrypt(key, nil)
|
||||||
|
if err != ErrInvalidCiphertext {
|
||||||
|
t.Error("decrypt nil should return ErrInvalidCiphertext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentHash(t *testing.T) {
|
||||||
|
data := []byte("test data")
|
||||||
|
hash1 := ContentHash(data)
|
||||||
|
hash2 := ContentHash(data)
|
||||||
|
|
||||||
|
if !bytes.Equal(hash1, hash2) {
|
||||||
|
t.Error("same data should produce same hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash3 := ContentHash([]byte("different data"))
|
||||||
|
if bytes.Equal(hash1, hash3) {
|
||||||
|
t.Error("different data should produce different hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hash1) != 32 {
|
||||||
|
t.Errorf("hash length should be 32, got %d", len(hash1))
|
||||||
|
}
|
||||||
|
}
|
||||||
143
lib/dbcore.go
143
lib/dbcore.go
|
|
@ -422,6 +422,149 @@ func UserByID(db *DB, userID string) (*User, error) {
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserCount returns the number of users in the database.
|
||||||
|
func UserCount(db *DB) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := db.Conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectsByUser returns all projects a user has access to.
|
||||||
|
func ProjectsByUser(db *DB, cfg *Config, userID string) ([]Entry, error) {
|
||||||
|
rows, err := db.Conn.Query(
|
||||||
|
`SELECT DISTINCT e.entry_id, e.project_id, e.parent_id, e.type, e.depth,
|
||||||
|
e.search_key, e.search_key2, e.summary, e.data, e.stage,
|
||||||
|
e.assignee_id, e.return_to_id, e.origin_id,
|
||||||
|
e.version, e.deleted_at, e.deleted_by, e.key_version,
|
||||||
|
e.created_at, e.updated_at, e.created_by
|
||||||
|
FROM entries e
|
||||||
|
JOIN access a ON a.project_id = e.project_id
|
||||||
|
WHERE a.user_id = ? AND a.revoked_at IS NULL AND e.type = 'project' AND e.deleted_at IS NULL
|
||||||
|
ORDER BY e.updated_at DESC`, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEntryRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := unpackEntry(cfg, e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, *e)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TasksByUser returns all entries assigned to a user across all projects.
|
||||||
|
func TasksByUser(db *DB, cfg *Config, userID string) ([]Entry, error) {
|
||||||
|
rows, err := db.Conn.Query(
|
||||||
|
`SELECT entry_id, project_id, parent_id, type, depth,
|
||||||
|
search_key, search_key2, summary, data, stage,
|
||||||
|
assignee_id, return_to_id, origin_id,
|
||||||
|
version, deleted_at, deleted_by, key_version,
|
||||||
|
created_at, updated_at, created_by
|
||||||
|
FROM entries
|
||||||
|
WHERE assignee_id = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC`, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEntryRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := unpackEntry(cfg, e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, *e)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntriesByParent returns entries with a given parent ID.
|
||||||
|
func EntriesByParent(db *DB, cfg *Config, parentID string) ([]Entry, error) {
|
||||||
|
rows, err := db.Conn.Query(
|
||||||
|
`SELECT entry_id, project_id, parent_id, type, depth,
|
||||||
|
search_key, search_key2, summary, data, stage,
|
||||||
|
assignee_id, return_to_id, origin_id,
|
||||||
|
version, deleted_at, deleted_by, key_version,
|
||||||
|
created_at, updated_at, created_by
|
||||||
|
FROM entries
|
||||||
|
WHERE parent_id = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at ASC`, parentID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEntryRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := unpackEntry(cfg, e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, *e)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntryByID returns a single entry by ID (with RBAC bypass for internal use).
|
||||||
|
func EntryByID(db *DB, cfg *Config, entryID string) (*Entry, error) {
|
||||||
|
e, err := entryReadSystem(db, entryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := unpackEntry(cfg, e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestCountByProject returns the number of requests in a project.
|
||||||
|
func RequestCountByProject(db *DB, projectID string) (int, int, error) {
|
||||||
|
var total, open int
|
||||||
|
err := db.Conn.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL`,
|
||||||
|
projectID,
|
||||||
|
).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
err = db.Conn.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL AND stage = 'pre_dataroom'`,
|
||||||
|
projectID,
|
||||||
|
).Scan(&open)
|
||||||
|
return total, open, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkstreamCountByProject returns the number of workstreams in a project.
|
||||||
|
func WorkstreamCountByProject(db *DB, projectID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := db.Conn.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'workstream' AND deleted_at IS NULL`,
|
||||||
|
projectID,
|
||||||
|
).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Access operations
|
// Access operations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,627 @@
|
||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testDB creates an in-memory SQLite database with migrations applied.
|
||||||
|
func testDB(t *testing.T) (*DB, *Config) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create temp file for SQLite (in-memory doesn't work well with WAL)
|
||||||
|
tmpFile, err := os.CreateTemp("", "dealspace-test-*.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp file: %v", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
t.Cleanup(func() { os.Remove(tmpFile.Name()) })
|
||||||
|
|
||||||
|
db, err := OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
masterKey := make([]byte, 32)
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
MasterKey: masterKey,
|
||||||
|
JWTSecret: []byte("test-jwt-secret"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// testUser creates a test user with the given role and returns the user ID.
|
||||||
|
func testUser(t *testing.T, db *DB, cfg *Config, projectID, role string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
userID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
user := &User{
|
||||||
|
UserID: userID,
|
||||||
|
Email: userID + "@test.com",
|
||||||
|
Name: "Test User",
|
||||||
|
Password: "$2a$10$test", // bcrypt placeholder
|
||||||
|
OrgID: "test-org",
|
||||||
|
OrgName: "Test Org",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := UserCreate(db, user); err != nil {
|
||||||
|
t.Fatalf("UserCreate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant access with appropriate ops based on role
|
||||||
|
ops := "r"
|
||||||
|
switch role {
|
||||||
|
case RoleIBAdmin, RoleIBMember:
|
||||||
|
ops = "rwdm"
|
||||||
|
case RoleSellerAdmin, RoleSellerMember:
|
||||||
|
ops = "rwd"
|
||||||
|
case RoleBuyerAdmin, RoleBuyerMember:
|
||||||
|
ops = "r"
|
||||||
|
}
|
||||||
|
|
||||||
|
access := &Access{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
ProjectID: projectID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: role,
|
||||||
|
Ops: ops,
|
||||||
|
CanGrant: role == RoleIBAdmin || role == RoleSellerAdmin,
|
||||||
|
GrantedBy: "system",
|
||||||
|
GrantedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := AccessGrant(db, access); err != nil {
|
||||||
|
t.Fatalf("AccessGrant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// testProject creates a test project entry and returns the project ID.
|
||||||
|
func testProject(t *testing.T, db *DB, cfg *Config, ownerID string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
projectID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
// First grant the owner access to create entries
|
||||||
|
access := &Access{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
ProjectID: projectID,
|
||||||
|
UserID: ownerID,
|
||||||
|
Role: RoleIBAdmin,
|
||||||
|
Ops: "rwdm",
|
||||||
|
CanGrant: true,
|
||||||
|
GrantedBy: "system",
|
||||||
|
GrantedAt: now,
|
||||||
|
}
|
||||||
|
if err := AccessGrant(db, access); err != nil {
|
||||||
|
t.Fatalf("AccessGrant for owner: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create project entry
|
||||||
|
entry := &Entry{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: TypeProject,
|
||||||
|
Depth: 0,
|
||||||
|
Stage: StagePreDataroom,
|
||||||
|
SummaryText: "Test Project",
|
||||||
|
DataText: `{"name": "Test Project"}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EntryWrite(db, cfg, ownerID, entry); err != nil {
|
||||||
|
t.Fatalf("EntryWrite project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryWriteRead(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Create owner user first
|
||||||
|
ownerID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
owner := &User{
|
||||||
|
UserID: ownerID,
|
||||||
|
Email: "owner@test.com",
|
||||||
|
Name: "Owner",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
OrgID: "test-org",
|
||||||
|
OrgName: "Test Org",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := UserCreate(db, owner); err != nil {
|
||||||
|
t.Fatalf("UserCreate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := testProject(t, db, cfg, ownerID)
|
||||||
|
|
||||||
|
// Write an entry
|
||||||
|
entry := &Entry{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: TypeRequest,
|
||||||
|
Depth: 1,
|
||||||
|
Stage: StagePreDataroom,
|
||||||
|
SummaryText: "Test Summary",
|
||||||
|
DataText: `{"question": "What is the answer?"}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EntryWrite(db, cfg, ownerID, entry); err != nil {
|
||||||
|
t.Fatalf("EntryWrite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.EntryID == "" {
|
||||||
|
t.Error("EntryID should be set after write")
|
||||||
|
}
|
||||||
|
if entry.Version != 1 {
|
||||||
|
t.Errorf("Version should be 1, got %d", entry.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read it back
|
||||||
|
filter := EntryFilter{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: TypeRequest,
|
||||||
|
}
|
||||||
|
entries, err := EntryRead(db, cfg, ownerID, projectID, filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryRead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := entries[0]
|
||||||
|
if got.EntryID != entry.EntryID {
|
||||||
|
t.Errorf("EntryID mismatch: got %s, want %s", got.EntryID, entry.EntryID)
|
||||||
|
}
|
||||||
|
if got.SummaryText != "Test Summary" {
|
||||||
|
t.Errorf("SummaryText mismatch: got %q, want %q", got.SummaryText, "Test Summary")
|
||||||
|
}
|
||||||
|
if got.DataText != `{"question": "What is the answer?"}` {
|
||||||
|
t.Errorf("DataText mismatch: got %q", got.DataText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryReadAccessDenied(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Create owner and project
|
||||||
|
ownerID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
owner := &User{
|
||||||
|
UserID: ownerID,
|
||||||
|
Email: "owner@test.com",
|
||||||
|
Name: "Owner",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := UserCreate(db, owner); err != nil {
|
||||||
|
t.Fatalf("UserCreate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := testProject(t, db, cfg, ownerID)
|
||||||
|
|
||||||
|
// Create another user with NO access to this project
|
||||||
|
noAccessUser := &User{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Email: "noaccess@test.com",
|
||||||
|
Name: "No Access",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := UserCreate(db, noAccessUser); err != nil {
|
||||||
|
t.Fatalf("UserCreate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read with no-access user
|
||||||
|
filter := EntryFilter{ProjectID: projectID}
|
||||||
|
_, err := EntryRead(db, cfg, noAccessUser.UserID, projectID, filter)
|
||||||
|
if err != ErrAccessDenied {
|
||||||
|
t.Errorf("expected ErrAccessDenied, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoftDelete(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
ownerID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
owner := &User{
|
||||||
|
UserID: ownerID,
|
||||||
|
Email: "owner@test.com",
|
||||||
|
Name: "Owner",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, owner)
|
||||||
|
projectID := testProject(t, db, cfg, ownerID)
|
||||||
|
|
||||||
|
// Create entry
|
||||||
|
entry := &Entry{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: TypeRequest,
|
||||||
|
Depth: 1,
|
||||||
|
Stage: StagePreDataroom,
|
||||||
|
SummaryText: "To be deleted",
|
||||||
|
}
|
||||||
|
EntryWrite(db, cfg, ownerID, entry)
|
||||||
|
entryID := entry.EntryID
|
||||||
|
|
||||||
|
// Delete it
|
||||||
|
if err := EntryDelete(db, ownerID, projectID, entryID); err != nil {
|
||||||
|
t.Fatalf("EntryDelete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify not returned in normal reads
|
||||||
|
filter := EntryFilter{ProjectID: projectID, Type: TypeRequest}
|
||||||
|
entries, err := EntryRead(db, cfg, ownerID, projectID, filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryRead: %v", err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.EntryID == entryID {
|
||||||
|
t.Error("deleted entry should not be returned in normal reads")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deleted_at is set via direct query
|
||||||
|
var deletedAt *int64
|
||||||
|
err = db.Conn.QueryRow("SELECT deleted_at FROM entries WHERE entry_id = ?", entryID).Scan(&deletedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query deleted_at: %v", err)
|
||||||
|
}
|
||||||
|
if deletedAt == nil {
|
||||||
|
t.Error("deleted_at should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptimisticLocking(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
ownerID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
owner := &User{
|
||||||
|
UserID: ownerID,
|
||||||
|
Email: "owner@test.com",
|
||||||
|
Name: "Owner",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, owner)
|
||||||
|
projectID := testProject(t, db, cfg, ownerID)
|
||||||
|
|
||||||
|
// Create entry v1
|
||||||
|
entry := &Entry{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: TypeRequest,
|
||||||
|
Depth: 1,
|
||||||
|
Stage: StagePreDataroom,
|
||||||
|
SummaryText: "Version 1",
|
||||||
|
}
|
||||||
|
EntryWrite(db, cfg, ownerID, entry)
|
||||||
|
if entry.Version != 1 {
|
||||||
|
t.Fatalf("initial version should be 1, got %d", entry.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update successfully to v2
|
||||||
|
entry.SummaryText = "Version 2"
|
||||||
|
if err := EntryWrite(db, cfg, ownerID, entry); err != nil {
|
||||||
|
t.Fatalf("EntryWrite v2: %v", err)
|
||||||
|
}
|
||||||
|
if entry.Version != 2 {
|
||||||
|
t.Fatalf("version should be 2, got %d", entry.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to update with stale version (v1)
|
||||||
|
staleEntry := &Entry{
|
||||||
|
EntryID: entry.EntryID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: TypeRequest,
|
||||||
|
Depth: 1,
|
||||||
|
Stage: StagePreDataroom,
|
||||||
|
SummaryText: "Stale update",
|
||||||
|
Version: 1, // Stale!
|
||||||
|
}
|
||||||
|
err := EntryWrite(db, cfg, ownerID, staleEntry)
|
||||||
|
if err != ErrVersionConflict {
|
||||||
|
t.Errorf("expected ErrVersionConflict, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlindIndexSearch(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
ownerID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
owner := &User{
|
||||||
|
UserID: ownerID,
|
||||||
|
Email: "owner@test.com",
|
||||||
|
Name: "Owner",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, owner)
|
||||||
|
projectID := testProject(t, db, cfg, ownerID)
|
||||||
|
|
||||||
|
// Create entry with search key
|
||||||
|
hmacKey, _ := DeriveHMACKey(cfg.MasterKey, projectID)
|
||||||
|
searchTerm := "unique-search-term-123"
|
||||||
|
blindIdx := BlindIndex(hmacKey, searchTerm)
|
||||||
|
|
||||||
|
entry := &Entry{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: TypeRequest,
|
||||||
|
Depth: 1,
|
||||||
|
Stage: StagePreDataroom,
|
||||||
|
SummaryText: "Searchable Entry",
|
||||||
|
SearchKey: blindIdx,
|
||||||
|
}
|
||||||
|
EntryWrite(db, cfg, ownerID, entry)
|
||||||
|
|
||||||
|
// Search by blind index
|
||||||
|
filter := EntryFilter{
|
||||||
|
ProjectID: projectID,
|
||||||
|
SearchKey: blindIdx,
|
||||||
|
}
|
||||||
|
entries, err := EntryRead(db, cfg, ownerID, projectID, filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryRead with SearchKey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].EntryID != entry.EntryID {
|
||||||
|
t.Error("wrong entry returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with different blind index should return nothing
|
||||||
|
wrongIdx := BlindIndex(hmacKey, "wrong-term")
|
||||||
|
filter.SearchKey = wrongIdx
|
||||||
|
entries, err = EntryRead(db, cfg, ownerID, projectID, filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryRead with wrong SearchKey: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("expected 0 entries with wrong search key, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserByEmail(t *testing.T) {
|
||||||
|
db, _ := testDB(t)
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
user := &User{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Email: "test@example.com",
|
||||||
|
Name: "Test User",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
OrgID: "test-org",
|
||||||
|
OrgName: "Test Org",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := UserCreate(db, user); err != nil {
|
||||||
|
t.Fatalf("UserCreate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find by email
|
||||||
|
found, err := UserByEmail(db, "test@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("user not found")
|
||||||
|
}
|
||||||
|
if found.UserID != user.UserID {
|
||||||
|
t.Error("wrong user returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
notFound, err := UserByEmail(db, "nonexistent@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByEmail nonexistent: %v", err)
|
||||||
|
}
|
||||||
|
if notFound != nil {
|
||||||
|
t.Error("should return nil for nonexistent email")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionCreateAndRevoke(t *testing.T) {
|
||||||
|
db, _ := testDB(t)
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
user := &User{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Email: "session@test.com",
|
||||||
|
Name: "Session User",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, user)
|
||||||
|
|
||||||
|
session := &Session{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
UserID: user.UserID,
|
||||||
|
Fingerprint: "test-fingerprint",
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now + 86400000, // +1 day
|
||||||
|
Revoked: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SessionCreate(db, session); err != nil {
|
||||||
|
t.Fatalf("SessionCreate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve session
|
||||||
|
found, err := SessionByID(db, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SessionByID: %v", err)
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("session not found")
|
||||||
|
}
|
||||||
|
if found.Revoked {
|
||||||
|
t.Error("session should not be revoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke
|
||||||
|
if err := SessionRevoke(db, session.ID); err != nil {
|
||||||
|
t.Fatalf("SessionRevoke: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check revoked
|
||||||
|
found, _ = SessionByID(db, session.ID)
|
||||||
|
if !found.Revoked {
|
||||||
|
t.Error("session should be revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuyerCannotSeePreDataroom(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Setup owner
|
||||||
|
ownerID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
owner := &User{
|
||||||
|
UserID: ownerID,
|
||||||
|
Email: "owner@test.com",
|
||||||
|
Name: "Owner",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, owner)
|
||||||
|
projectID := testProject(t, db, cfg, ownerID)
|
||||||
|
|
||||||
|
// Create pre_dataroom entry
|
||||||
|
preEntry := &Entry{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: TypeRequest,
|
||||||
|
Depth: 1,
|
||||||
|
Stage: StagePreDataroom,
|
||||||
|
SummaryText: "Pre-dataroom entry",
|
||||||
|
}
|
||||||
|
EntryWrite(db, cfg, ownerID, preEntry)
|
||||||
|
|
||||||
|
// Create dataroom entry
|
||||||
|
drEntry := &Entry{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: TypeRequest,
|
||||||
|
Depth: 1,
|
||||||
|
Stage: StageDataroom,
|
||||||
|
SummaryText: "Dataroom entry",
|
||||||
|
}
|
||||||
|
EntryWrite(db, cfg, ownerID, drEntry)
|
||||||
|
|
||||||
|
// Create buyer user
|
||||||
|
buyerID := testUser(t, db, cfg, projectID, RoleBuyerMember)
|
||||||
|
|
||||||
|
// Buyer reads entries
|
||||||
|
filter := EntryFilter{ProjectID: projectID, Type: TypeRequest}
|
||||||
|
entries, err := EntryRead(db, cfg, buyerID, projectID, filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryRead as buyer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only see dataroom entry
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("buyer should see 1 entry (dataroom only), got %d", len(entries))
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Stage == StagePreDataroom {
|
||||||
|
t.Error("buyer should not see pre_dataroom entries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner should see both
|
||||||
|
entries, _ = EntryRead(db, cfg, ownerID, projectID, filter)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Errorf("owner should see 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalStore(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "dealspace-store-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
store, err := NewLocalStore(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocalStore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write
|
||||||
|
data := []byte("test object data")
|
||||||
|
id := "abcdef1234567890"
|
||||||
|
if err := store.Write(id, data); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists
|
||||||
|
if !store.Exists(id) {
|
||||||
|
t.Error("object should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read
|
||||||
|
read, err := store.Read(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(read, data) {
|
||||||
|
t.Error("data mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
if err := store.Delete(id); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if store.Exists(id) {
|
||||||
|
t.Error("object should not exist after delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read nonexistent
|
||||||
|
_, err = store.Read("nonexistent")
|
||||||
|
if err != ErrObjectNotFound {
|
||||||
|
t.Errorf("expected ErrObjectNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mailer handles sending emails via SMTP.
|
||||||
|
type Mailer struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Pass string
|
||||||
|
From string
|
||||||
|
templates *template.Template
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMailer creates a new Mailer from environment variables.
|
||||||
|
// If SMTP_HOST is empty, returns a no-op mailer.
|
||||||
|
func NewMailer(cfg *Config) *Mailer {
|
||||||
|
host := os.Getenv("SMTP_HOST")
|
||||||
|
if host == "" {
|
||||||
|
return &Mailer{enabled: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
portStr := os.Getenv("SMTP_PORT")
|
||||||
|
port := 587 // default
|
||||||
|
if portStr != "" {
|
||||||
|
if p, err := strconv.Atoi(portStr); err == nil {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
from := os.Getenv("SMTP_FROM")
|
||||||
|
if from == "" {
|
||||||
|
from = "noreply@muskepo.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Mailer{
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
User: os.Getenv("SMTP_USER"),
|
||||||
|
Pass: os.Getenv("SMTP_PASS"),
|
||||||
|
From: from,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadTemplates loads email templates from the given directory.
|
||||||
|
// Templates should be in portal/emails/ directory.
|
||||||
|
func (m *Mailer) LoadTemplates(templateDir string) error {
|
||||||
|
if !m.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse base template first, then all others
|
||||||
|
pattern := filepath.Join(templateDir, "*.html")
|
||||||
|
tmpl, err := template.New("").Funcs(emailFuncs()).ParseGlob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse email templates: %w", err)
|
||||||
|
}
|
||||||
|
m.templates = tmpl
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailFuncs returns template functions for email templates.
|
||||||
|
func emailFuncs() template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
"gt": func(a, b int) bool { return a > b },
|
||||||
|
"sub": func(a, b int) int { return a - b },
|
||||||
|
"truncate": func(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled returns true if the mailer is configured and can send emails.
|
||||||
|
func (m *Mailer) Enabled() bool {
|
||||||
|
return m.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends an email with the given HTML body.
|
||||||
|
func (m *Mailer) Send(to, subject, htmlBody string) error {
|
||||||
|
if !m.enabled {
|
||||||
|
return nil // no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build email message
|
||||||
|
msg := m.buildMessage(to, subject, htmlBody)
|
||||||
|
|
||||||
|
// Connect and send
|
||||||
|
addr := fmt.Sprintf("%s:%d", m.Host, m.Port)
|
||||||
|
|
||||||
|
var auth smtp.Auth
|
||||||
|
if m.User != "" && m.Pass != "" {
|
||||||
|
auth = smtp.PlainAuth("", m.User, m.Pass, m.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := smtp.SendMail(addr, auth, m.From, []string{to}, msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send mail to %s: %w", to, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTemplate renders a template and sends it as an email.
|
||||||
|
func (m *Mailer) SendTemplate(to, subject, tmplName string, data any) error {
|
||||||
|
if !m.enabled {
|
||||||
|
return nil // no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.templates == nil {
|
||||||
|
return fmt.Errorf("templates not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render template
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := m.templates.ExecuteTemplate(&buf, tmplName, data); err != nil {
|
||||||
|
return fmt.Errorf("render template %s: %w", tmplName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.Send(to, subject, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderTemplate renders a template to a string without sending.
|
||||||
|
// Useful for testing and previewing emails.
|
||||||
|
func (m *Mailer) RenderTemplate(tmplName string, data any) (string, error) {
|
||||||
|
if m.templates == nil {
|
||||||
|
return "", fmt.Errorf("templates not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := m.templates.ExecuteTemplate(&buf, tmplName, data); err != nil {
|
||||||
|
return "", fmt.Errorf("render template %s: %w", tmplName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessage constructs an RFC 2822 email message.
|
||||||
|
func (m *Mailer) buildMessage(to, subject, htmlBody string) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
buf.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
|
||||||
|
buf.WriteString(fmt.Sprintf("From: Dealspace <%s>\r\n", m.From))
|
||||||
|
buf.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
||||||
|
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", m.encodeSubject(subject)))
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
|
||||||
|
// Body
|
||||||
|
buf.WriteString(htmlBody)
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeSubject encodes the subject line for non-ASCII characters.
|
||||||
|
func (m *Mailer) encodeSubject(subject string) string {
|
||||||
|
// Check if subject contains non-ASCII
|
||||||
|
for _, r := range subject {
|
||||||
|
if r > 127 {
|
||||||
|
// Use RFC 2047 encoding
|
||||||
|
return "=?UTF-8?B?" + base64Encode(subject) + "?="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64Encode encodes a string to base64.
|
||||||
|
func base64Encode(s string) string {
|
||||||
|
const base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
data := []byte(s)
|
||||||
|
var result strings.Builder
|
||||||
|
for i := 0; i < len(data); i += 3 {
|
||||||
|
var n uint32
|
||||||
|
remain := len(data) - i
|
||||||
|
if remain >= 3 {
|
||||||
|
n = uint32(data[i])<<16 | uint32(data[i+1])<<8 | uint32(data[i+2])
|
||||||
|
result.WriteByte(base64Chars[n>>18&63])
|
||||||
|
result.WriteByte(base64Chars[n>>12&63])
|
||||||
|
result.WriteByte(base64Chars[n>>6&63])
|
||||||
|
result.WriteByte(base64Chars[n&63])
|
||||||
|
} else if remain == 2 {
|
||||||
|
n = uint32(data[i])<<16 | uint32(data[i+1])<<8
|
||||||
|
result.WriteByte(base64Chars[n>>18&63])
|
||||||
|
result.WriteByte(base64Chars[n>>12&63])
|
||||||
|
result.WriteByte(base64Chars[n>>6&63])
|
||||||
|
result.WriteByte('=')
|
||||||
|
} else {
|
||||||
|
n = uint32(data[i]) << 16
|
||||||
|
result.WriteByte(base64Chars[n>>18&63])
|
||||||
|
result.WriteByte(base64Chars[n>>12&63])
|
||||||
|
result.WriteString("==")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Email Data Structures ----
|
||||||
|
|
||||||
|
// InviteData is the data for invite.html template.
|
||||||
|
type InviteData struct {
|
||||||
|
InviterName string
|
||||||
|
InviterOrg string
|
||||||
|
ProjectName string
|
||||||
|
InviteURL string
|
||||||
|
RecipientName string
|
||||||
|
Role string
|
||||||
|
ExpiresIn string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TasksAssignedData is the data for tasks_assigned.html template.
|
||||||
|
type TasksAssignedData struct {
|
||||||
|
RecipientName string
|
||||||
|
ProjectName string
|
||||||
|
Count int
|
||||||
|
Tasks []TaskItem
|
||||||
|
TasksURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskItem represents a single task in the tasks list.
|
||||||
|
type TaskItem struct {
|
||||||
|
Title string
|
||||||
|
DueDate string
|
||||||
|
Priority string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerSubmittedData is the data for answer_submitted.html template.
|
||||||
|
type AnswerSubmittedData struct {
|
||||||
|
RecipientName string
|
||||||
|
AnswererName string
|
||||||
|
RequestTitle string
|
||||||
|
WorkstreamName string
|
||||||
|
AnswerPreview string
|
||||||
|
ReviewURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerApprovedData is the data for answer_approved.html template.
|
||||||
|
type AnswerApprovedData struct {
|
||||||
|
RecipientName string
|
||||||
|
RequestTitle string
|
||||||
|
Published bool
|
||||||
|
DataRoomURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerRejectedData is the data for answer_rejected.html template.
|
||||||
|
type AnswerRejectedData struct {
|
||||||
|
RecipientName string
|
||||||
|
RequestTitle string
|
||||||
|
Reason string
|
||||||
|
RequestURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestForwardedData is the data for request_forwarded.html template.
|
||||||
|
type RequestForwardedData struct {
|
||||||
|
RecipientName string
|
||||||
|
SenderName string
|
||||||
|
RequestTitle string
|
||||||
|
RequestURL string
|
||||||
|
DueDate string
|
||||||
|
HasDueDate bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WelcomeData is the data for welcome.html template.
|
||||||
|
type WelcomeData struct {
|
||||||
|
RecipientName string
|
||||||
|
TasksURL string
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,387 @@
|
||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckAccess(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Create owner
|
||||||
|
ownerID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
owner := &User{
|
||||||
|
UserID: ownerID,
|
||||||
|
Email: "owner@test.com",
|
||||||
|
Name: "Owner",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, owner)
|
||||||
|
projectID := testProject(t, db, cfg, ownerID)
|
||||||
|
|
||||||
|
// IB admin can read
|
||||||
|
err := CheckAccessRead(db, ownerID, projectID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("IB admin should have read access: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IB admin can write
|
||||||
|
err = CheckAccessWrite(db, ownerID, projectID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("IB admin should have write access: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IB admin can delete
|
||||||
|
err = CheckAccessDelete(db, ownerID, projectID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("IB admin should have delete access: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create seller user
|
||||||
|
sellerID := testUser(t, db, cfg, projectID, RoleSellerMember)
|
||||||
|
|
||||||
|
// Seller can read
|
||||||
|
err = CheckAccessRead(db, sellerID, projectID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("seller should have read access: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seller can write
|
||||||
|
err = CheckAccessWrite(db, sellerID, projectID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("seller should have write access: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create buyer user (read-only)
|
||||||
|
buyerID := testUser(t, db, cfg, projectID, RoleBuyerMember)
|
||||||
|
|
||||||
|
// Buyer can read
|
||||||
|
err = CheckAccessRead(db, buyerID, projectID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("buyer should have read access: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buyer cannot write
|
||||||
|
err = CheckAccessWrite(db, buyerID, projectID, "")
|
||||||
|
if err != ErrAccessDenied {
|
||||||
|
t.Errorf("buyer should NOT have write access, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleHierarchy(t *testing.T) {
|
||||||
|
// Verify hierarchy levels: buyer < seller_member < seller_admin < ib_analyst < ib_member < ib_admin
|
||||||
|
expected := []struct {
|
||||||
|
role string
|
||||||
|
level int
|
||||||
|
}{
|
||||||
|
{RoleObserver, 10},
|
||||||
|
{RoleBuyerMember, 30},
|
||||||
|
{RoleBuyerAdmin, 40},
|
||||||
|
{RoleSellerMember, 50},
|
||||||
|
{RoleSellerAdmin, 70},
|
||||||
|
{RoleIBMember, 80},
|
||||||
|
{RoleIBAdmin, 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range expected {
|
||||||
|
level, ok := RoleHierarchy[tc.role]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("role %s not in hierarchy", tc.role)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if level != tc.level {
|
||||||
|
t.Errorf("role %s: expected level %d, got %d", tc.role, tc.level, level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ordering
|
||||||
|
if RoleHierarchy[RoleBuyerMember] >= RoleHierarchy[RoleSellerMember] {
|
||||||
|
t.Error("buyer should be lower than seller_member")
|
||||||
|
}
|
||||||
|
if RoleHierarchy[RoleSellerMember] >= RoleHierarchy[RoleSellerAdmin] {
|
||||||
|
t.Error("seller_member should be lower than seller_admin")
|
||||||
|
}
|
||||||
|
if RoleHierarchy[RoleSellerAdmin] >= RoleHierarchy[RoleIBMember] {
|
||||||
|
t.Error("seller_admin should be lower than ib_member")
|
||||||
|
}
|
||||||
|
if RoleHierarchy[RoleIBMember] >= RoleHierarchy[RoleIBAdmin] {
|
||||||
|
t.Error("ib_member should be lower than ib_admin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanGrantRole(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
granter string
|
||||||
|
target string
|
||||||
|
canDo bool
|
||||||
|
}{
|
||||||
|
// IB admin can grant anything
|
||||||
|
{RoleIBAdmin, RoleIBAdmin, true},
|
||||||
|
{RoleIBAdmin, RoleIBMember, true},
|
||||||
|
{RoleIBAdmin, RoleSellerAdmin, true},
|
||||||
|
{RoleIBAdmin, RoleBuyerMember, true},
|
||||||
|
|
||||||
|
// IB member can grant lower roles
|
||||||
|
{RoleIBMember, RoleIBAdmin, false},
|
||||||
|
{RoleIBMember, RoleIBMember, true},
|
||||||
|
{RoleIBMember, RoleSellerAdmin, true},
|
||||||
|
|
||||||
|
// Seller admin can grant seller and buyer roles
|
||||||
|
{RoleSellerAdmin, RoleIBMember, false},
|
||||||
|
{RoleSellerAdmin, RoleSellerAdmin, true},
|
||||||
|
{RoleSellerAdmin, RoleSellerMember, true},
|
||||||
|
{RoleSellerAdmin, RoleBuyerMember, true},
|
||||||
|
|
||||||
|
// Buyer cannot grant higher roles
|
||||||
|
{RoleBuyerAdmin, RoleSellerMember, false},
|
||||||
|
{RoleBuyerAdmin, RoleBuyerAdmin, true},
|
||||||
|
{RoleBuyerAdmin, RoleBuyerMember, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
result := CanGrantRole(tc.granter, tc.target)
|
||||||
|
if result != tc.canDo {
|
||||||
|
t.Errorf("CanGrantRole(%s, %s) = %v, want %v", tc.granter, tc.target, result, tc.canDo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrantRevoke(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Create admin
|
||||||
|
adminID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
admin := &User{
|
||||||
|
UserID: adminID,
|
||||||
|
Email: "admin@test.com",
|
||||||
|
Name: "Admin",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, admin)
|
||||||
|
projectID := testProject(t, db, cfg, adminID)
|
||||||
|
|
||||||
|
// Create user with no access
|
||||||
|
userID := uuid.New().String()
|
||||||
|
user := &User{
|
||||||
|
UserID: userID,
|
||||||
|
Email: "user@test.com",
|
||||||
|
Name: "User",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, user)
|
||||||
|
|
||||||
|
// Verify no access
|
||||||
|
err := CheckAccessRead(db, userID, projectID, "")
|
||||||
|
if err != ErrAccessDenied {
|
||||||
|
t.Error("user should have no access initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant access
|
||||||
|
accessID := uuid.New().String()
|
||||||
|
access := &Access{
|
||||||
|
ID: accessID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: RoleBuyerMember,
|
||||||
|
Ops: "r",
|
||||||
|
CanGrant: false,
|
||||||
|
GrantedBy: adminID,
|
||||||
|
GrantedAt: now,
|
||||||
|
}
|
||||||
|
if err := AccessGrant(db, access); err != nil {
|
||||||
|
t.Fatalf("AccessGrant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access granted
|
||||||
|
err = CheckAccessRead(db, userID, projectID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("user should have read access after grant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke access
|
||||||
|
if err := AccessRevoke(db, accessID, adminID); err != nil {
|
||||||
|
t.Fatalf("AccessRevoke: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access revoked
|
||||||
|
err = CheckAccessRead(db, userID, projectID, "")
|
||||||
|
if err != ErrAccessDenied {
|
||||||
|
t.Error("user should have no access after revoke")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBuyerRole(t *testing.T) {
|
||||||
|
buyers := []string{RoleBuyerAdmin, RoleBuyerMember}
|
||||||
|
nonBuyers := []string{RoleIBAdmin, RoleIBMember, RoleSellerAdmin, RoleSellerMember, RoleObserver}
|
||||||
|
|
||||||
|
for _, role := range buyers {
|
||||||
|
if !IsBuyerRole(role) {
|
||||||
|
t.Errorf("%s should be buyer role", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, role := range nonBuyers {
|
||||||
|
if IsBuyerRole(role) {
|
||||||
|
t.Errorf("%s should NOT be buyer role", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserHighestRole(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Create admin and project
|
||||||
|
adminID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
admin := &User{
|
||||||
|
UserID: adminID,
|
||||||
|
Email: "admin@test.com",
|
||||||
|
Name: "Admin",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, admin)
|
||||||
|
projectID := testProject(t, db, cfg, adminID)
|
||||||
|
|
||||||
|
// Admin's highest role should be ib_admin
|
||||||
|
role, err := GetUserHighestRole(db, adminID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserHighestRole: %v", err)
|
||||||
|
}
|
||||||
|
if role != RoleIBAdmin {
|
||||||
|
t.Errorf("expected ib_admin, got %s", role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user with multiple roles
|
||||||
|
userID := uuid.New().String()
|
||||||
|
user := &User{
|
||||||
|
UserID: userID,
|
||||||
|
Email: "multi@test.com",
|
||||||
|
Name: "Multi Role User",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, user)
|
||||||
|
|
||||||
|
// Grant buyer role
|
||||||
|
AccessGrant(db, &Access{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
ProjectID: projectID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: RoleBuyerMember,
|
||||||
|
Ops: "r",
|
||||||
|
GrantedBy: adminID,
|
||||||
|
GrantedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Grant seller role (higher)
|
||||||
|
AccessGrant(db, &Access{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
ProjectID: projectID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: RoleSellerMember,
|
||||||
|
Ops: "rw",
|
||||||
|
GrantedBy: adminID,
|
||||||
|
GrantedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Highest should be seller_member
|
||||||
|
role, err = GetUserHighestRole(db, userID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserHighestRole: %v", err)
|
||||||
|
}
|
||||||
|
if role != RoleSellerMember {
|
||||||
|
t.Errorf("expected seller_member (highest), got %s", role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User with no access
|
||||||
|
noAccessID := uuid.New().String()
|
||||||
|
noAccessUser := &User{
|
||||||
|
UserID: noAccessID,
|
||||||
|
Email: "noaccess@test.com",
|
||||||
|
Name: "No Access",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, noAccessUser)
|
||||||
|
|
||||||
|
_, err = GetUserHighestRole(db, noAccessID, projectID)
|
||||||
|
if err != ErrAccessDenied {
|
||||||
|
t.Errorf("expected ErrAccessDenied for user with no access, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkstreamAccess(t *testing.T) {
|
||||||
|
db, cfg := testDB(t)
|
||||||
|
|
||||||
|
// Create admin and project
|
||||||
|
adminID := uuid.New().String()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
admin := &User{
|
||||||
|
UserID: adminID,
|
||||||
|
Email: "admin@test.com",
|
||||||
|
Name: "Admin",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, admin)
|
||||||
|
projectID := testProject(t, db, cfg, adminID)
|
||||||
|
|
||||||
|
// Create user with access to specific workstream only
|
||||||
|
userID := uuid.New().String()
|
||||||
|
user := &User{
|
||||||
|
UserID: userID,
|
||||||
|
Email: "ws@test.com",
|
||||||
|
Name: "Workstream User",
|
||||||
|
Password: "$2a$10$test",
|
||||||
|
Active: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
UserCreate(db, user)
|
||||||
|
|
||||||
|
workstreamID := "workstream-1"
|
||||||
|
AccessGrant(db, &Access{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
ProjectID: projectID,
|
||||||
|
WorkstreamID: workstreamID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: RoleSellerMember,
|
||||||
|
Ops: "rw",
|
||||||
|
GrantedBy: adminID,
|
||||||
|
GrantedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// User has access to their workstream
|
||||||
|
_, err := CheckAccess(db, userID, projectID, workstreamID, "r")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("user should have access to their workstream: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User should NOT have access to different workstream
|
||||||
|
_, err = CheckAccess(db, userID, projectID, "different-workstream", "r")
|
||||||
|
if err != ErrAccessDenied {
|
||||||
|
t.Error("user should NOT have access to different workstream")
|
||||||
|
}
|
||||||
|
}
|
||||||
582
lib/watermark.go
582
lib/watermark.go
|
|
@ -1,43 +1,571 @@
|
||||||
package lib
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||||
|
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
||||||
|
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types"
|
||||||
|
)
|
||||||
|
|
||||||
// Watermark applies per-request watermarks at serve time.
|
// Watermark applies per-request watermarks at serve time.
|
||||||
// Stored files are always clean originals; watermarks are injected on read.
|
// Stored files are always clean originals; watermarks are injected on read.
|
||||||
//
|
//
|
||||||
// Supported types:
|
// Supported types:
|
||||||
// - PDF: dynamic watermark (user + timestamp + org)
|
// - PDF: diagonal text watermark (pdfcpu)
|
||||||
// - Word (.docx): watermark injected into document XML
|
// - Word (.docx): watermark shape in header
|
||||||
// - Excel (.xlsx): sheet protection + watermark header row
|
// - Images: text overlay in bottom-right
|
||||||
// - Images: watermark text burned into pixel data
|
// - Other: pass-through unchanged
|
||||||
// - Video: watermark overlay via ffmpeg, served as stream
|
|
||||||
// - Other: encrypted download only, no preview
|
|
||||||
|
|
||||||
// WatermarkConfig holds per-project watermark settings.
|
// Watermark dispatches to the appropriate watermarking function based on MIME type.
|
||||||
type WatermarkConfig struct {
|
func Watermark(in []byte, mimeType string, label string) ([]byte, error) {
|
||||||
Template string // e.g. "{user_name} · {org_name} · {datetime} · CONFIDENTIAL"
|
switch mimeType {
|
||||||
Opacity float64
|
case "application/pdf":
|
||||||
Position string // "diagonal" | "footer" | "header"
|
return WatermarkPDF(in, label)
|
||||||
|
case "image/jpeg", "image/png", "image/gif":
|
||||||
|
return WatermarkImage(in, mimeType, label)
|
||||||
|
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||||
|
return WatermarkDOCX(in, label)
|
||||||
|
default:
|
||||||
|
return in, nil // pass through unsupported types
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatermarkPDF applies a watermark to PDF data. Stub.
|
// WatermarkPDF applies a diagonal text watermark to every page of a PDF.
|
||||||
func WatermarkPDF(data []byte, userName, orgName string, wc *WatermarkConfig) ([]byte, error) {
|
// Uses pdfcpu for PDF manipulation.
|
||||||
// TODO: implement PDF watermarking
|
// Label format: "CONFIDENTIAL — {UserName} — {Date} — {ProjectName}"
|
||||||
return data, nil
|
func WatermarkPDF(in []byte, label string) ([]byte, error) {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty input")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build watermark description string for pdfcpu
|
||||||
|
// Format: "text, fontname:Helvetica, fontsize:36, color:0.5 0.5 0.5, opacity:0.3, rotation:45, diagonal:1, scale:1 abs"
|
||||||
|
wmDesc := fmt.Sprintf("%s, fontname:Helvetica, fontsize:36, color:0.5 0.5 0.5, opacity:0.3, rotation:45, diagonal:1, scale:1 abs",
|
||||||
|
escapeWatermarkText(label))
|
||||||
|
|
||||||
|
wm, err := api.TextWatermark(wmDesc, "", true, false, types.POINTS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create watermark: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply watermark to all pages
|
||||||
|
var out bytes.Buffer
|
||||||
|
inReader := bytes.NewReader(in)
|
||||||
|
|
||||||
|
conf := model.NewDefaultConfiguration()
|
||||||
|
conf.ValidationMode = model.ValidationRelaxed
|
||||||
|
|
||||||
|
if err := api.AddWatermarks(inReader, &out, nil, wm, conf); err != nil {
|
||||||
|
return nil, fmt.Errorf("apply watermark: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatermarkDOCX applies a watermark to a Word document. Stub.
|
// escapeWatermarkText escapes special characters for pdfcpu watermark text.
|
||||||
func WatermarkDOCX(data []byte, userName, orgName string, wc *WatermarkConfig) ([]byte, error) {
|
func escapeWatermarkText(text string) string {
|
||||||
// TODO: implement DOCX watermarking
|
// Escape commas and colons which have special meaning in pdfcpu
|
||||||
return data, nil
|
text = strings.ReplaceAll(text, ",", "\\,")
|
||||||
|
text = strings.ReplaceAll(text, ":", "\\:")
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatermarkXLSX applies a watermark to an Excel spreadsheet. Stub.
|
// WatermarkImage applies a text watermark to an image.
|
||||||
func WatermarkXLSX(data []byte, userName, orgName string, wc *WatermarkConfig) ([]byte, error) {
|
// Supports JPEG, PNG, and GIF (first frame only for GIF).
|
||||||
// TODO: implement XLSX watermarking
|
// The watermark is placed in the bottom-right corner with semi-transparent white text and dark shadow.
|
||||||
return data, nil
|
func WatermarkImage(in []byte, mimeType string, label string) ([]byte, error) {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty input")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bytes.NewReader(in)
|
||||||
|
|
||||||
|
// Handle GIF specially (only watermark first frame)
|
||||||
|
if mimeType == "image/gif" {
|
||||||
|
return watermarkGIF(reader, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode image
|
||||||
|
img, format, err := image.Decode(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new RGBA image to draw on
|
||||||
|
bounds := img.Bounds()
|
||||||
|
rgba := image.NewRGBA(bounds)
|
||||||
|
draw.Draw(rgba, bounds, img, bounds.Min, draw.Src)
|
||||||
|
|
||||||
|
// Draw watermark text
|
||||||
|
drawWatermarkText(rgba, label)
|
||||||
|
|
||||||
|
// Encode output
|
||||||
|
var out bytes.Buffer
|
||||||
|
switch format {
|
||||||
|
case "jpeg":
|
||||||
|
if err := jpeg.Encode(&out, rgba, &jpeg.Options{Quality: 90}); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode jpeg: %w", err)
|
||||||
|
}
|
||||||
|
case "png":
|
||||||
|
if err := png.Encode(&out, rgba); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode png: %w", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// For other formats decoded by image.Decode, output as PNG
|
||||||
|
if err := png.Encode(&out, rgba); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode png: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatermarkImage applies a watermark to image data. Stub.
|
// watermarkGIF handles GIF watermarking (first frame only).
|
||||||
func WatermarkImage(data []byte, userName, orgName string, wc *WatermarkConfig) ([]byte, error) {
|
func watermarkGIF(reader io.Reader, label string) ([]byte, error) {
|
||||||
// TODO: implement image watermarking
|
g, err := gif.DecodeAll(reader)
|
||||||
return data, nil
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode gif: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Image) == 0 {
|
||||||
|
return nil, fmt.Errorf("gif has no frames")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watermark first frame
|
||||||
|
firstFrame := g.Image[0]
|
||||||
|
bounds := firstFrame.Bounds()
|
||||||
|
rgba := image.NewRGBA(bounds)
|
||||||
|
draw.Draw(rgba, bounds, firstFrame, bounds.Min, draw.Src)
|
||||||
|
|
||||||
|
drawWatermarkText(rgba, label)
|
||||||
|
|
||||||
|
// Convert back to paletted image
|
||||||
|
paletted := image.NewPaletted(bounds, firstFrame.Palette)
|
||||||
|
draw.Draw(paletted, bounds, rgba, bounds.Min, draw.Src)
|
||||||
|
g.Image[0] = paletted
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := gif.EncodeAll(&out, g); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode gif: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawWatermarkText draws watermark text on an RGBA image.
|
||||||
|
// Uses a simple pixel-based text rendering (8x8 bitmap font style).
|
||||||
|
// Text is placed in bottom-right corner with shadow effect.
|
||||||
|
func drawWatermarkText(img *image.RGBA, label string) {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
width := bounds.Dx()
|
||||||
|
height := bounds.Dy()
|
||||||
|
|
||||||
|
// Calculate font scale based on image size
|
||||||
|
scale := 1
|
||||||
|
if width > 1000 {
|
||||||
|
scale = 2
|
||||||
|
}
|
||||||
|
if width > 2000 {
|
||||||
|
scale = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure text
|
||||||
|
charWidth := 6 * scale
|
||||||
|
charHeight := 10 * scale
|
||||||
|
textWidth := len(label) * charWidth
|
||||||
|
textHeight := charHeight
|
||||||
|
|
||||||
|
// Position: bottom-right with padding
|
||||||
|
padding := 10 * scale
|
||||||
|
x := width - textWidth - padding
|
||||||
|
y := height - textHeight - padding
|
||||||
|
|
||||||
|
// Clamp to image bounds
|
||||||
|
if x < padding {
|
||||||
|
x = padding
|
||||||
|
}
|
||||||
|
if y < padding {
|
||||||
|
y = padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw shadow (dark, offset by 1-2 pixels)
|
||||||
|
shadowColor := color.RGBA{0, 0, 0, 180}
|
||||||
|
for i := 1; i <= scale; i++ {
|
||||||
|
drawText(img, label, x+i, y+i, charWidth, charHeight, shadowColor, scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw main text (semi-transparent white)
|
||||||
|
textColor := color.RGBA{255, 255, 255, 200}
|
||||||
|
drawText(img, label, x, y, charWidth, charHeight, textColor, scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawText draws text using a simple bitmap font approach.
|
||||||
|
// This is a basic implementation that renders readable ASCII text.
|
||||||
|
func drawText(img *image.RGBA, text string, startX, startY, charWidth, charHeight int, c color.Color, scale int) {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
|
||||||
|
for i, ch := range text {
|
||||||
|
x := startX + i*charWidth
|
||||||
|
if x >= bounds.Max.X {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get character bitmap
|
||||||
|
bitmap := getCharBitmap(ch)
|
||||||
|
if bitmap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw character
|
||||||
|
for row := 0; row < 8; row++ {
|
||||||
|
for col := 0; col < 5; col++ {
|
||||||
|
if bitmap[row]&(1<<(4-col)) != 0 {
|
||||||
|
// Draw scaled pixel
|
||||||
|
for sy := 0; sy < scale; sy++ {
|
||||||
|
for sx := 0; sx < scale; sx++ {
|
||||||
|
px := x + col*scale + sx
|
||||||
|
py := startY + row*scale + sy
|
||||||
|
if px >= bounds.Min.X && px < bounds.Max.X && py >= bounds.Min.Y && py < bounds.Max.Y {
|
||||||
|
img.Set(px, py, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCharBitmap returns an 8-row bitmap for a character (5 bits per row).
|
||||||
|
// Basic ASCII font supporting uppercase, lowercase, digits, and common punctuation.
|
||||||
|
func getCharBitmap(ch rune) []byte {
|
||||||
|
// 5x8 bitmap font - each byte represents one row, 5 bits used
|
||||||
|
fonts := map[rune][]byte{
|
||||||
|
'A': {0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11, 0x00},
|
||||||
|
'B': {0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E, 0x00},
|
||||||
|
'C': {0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E, 0x00},
|
||||||
|
'D': {0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E, 0x00},
|
||||||
|
'E': {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F, 0x00},
|
||||||
|
'F': {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10, 0x00},
|
||||||
|
'G': {0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E, 0x00},
|
||||||
|
'H': {0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11, 0x00},
|
||||||
|
'I': {0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00},
|
||||||
|
'J': {0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C, 0x00},
|
||||||
|
'K': {0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11, 0x00},
|
||||||
|
'L': {0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F, 0x00},
|
||||||
|
'M': {0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11, 0x00},
|
||||||
|
'N': {0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11, 0x00},
|
||||||
|
'O': {0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00},
|
||||||
|
'P': {0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10, 0x00},
|
||||||
|
'Q': {0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D, 0x00},
|
||||||
|
'R': {0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11, 0x00},
|
||||||
|
'S': {0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E, 0x00},
|
||||||
|
'T': {0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00},
|
||||||
|
'U': {0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00},
|
||||||
|
'V': {0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04, 0x00},
|
||||||
|
'W': {0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11, 0x00},
|
||||||
|
'X': {0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11, 0x00},
|
||||||
|
'Y': {0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04, 0x00},
|
||||||
|
'Z': {0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F, 0x00},
|
||||||
|
'a': {0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F, 0x00},
|
||||||
|
'b': {0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x1E, 0x00},
|
||||||
|
'c': {0x00, 0x00, 0x0E, 0x11, 0x10, 0x11, 0x0E, 0x00},
|
||||||
|
'd': {0x01, 0x01, 0x0F, 0x11, 0x11, 0x11, 0x0F, 0x00},
|
||||||
|
'e': {0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E, 0x00},
|
||||||
|
'f': {0x06, 0x09, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x00},
|
||||||
|
'g': {0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x0E, 0x00},
|
||||||
|
'h': {0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x11, 0x00},
|
||||||
|
'i': {0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x0E, 0x00},
|
||||||
|
'j': {0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C, 0x00},
|
||||||
|
'k': {0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12, 0x00},
|
||||||
|
'l': {0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00},
|
||||||
|
'm': {0x00, 0x00, 0x1A, 0x15, 0x15, 0x15, 0x15, 0x00},
|
||||||
|
'n': {0x00, 0x00, 0x1E, 0x11, 0x11, 0x11, 0x11, 0x00},
|
||||||
|
'o': {0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E, 0x00},
|
||||||
|
'p': {0x00, 0x00, 0x1E, 0x11, 0x1E, 0x10, 0x10, 0x00},
|
||||||
|
'q': {0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x01, 0x00},
|
||||||
|
'r': {0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10, 0x00},
|
||||||
|
's': {0x00, 0x00, 0x0F, 0x10, 0x0E, 0x01, 0x1E, 0x00},
|
||||||
|
't': {0x08, 0x08, 0x1C, 0x08, 0x08, 0x09, 0x06, 0x00},
|
||||||
|
'u': {0x00, 0x00, 0x11, 0x11, 0x11, 0x11, 0x0F, 0x00},
|
||||||
|
'v': {0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04, 0x00},
|
||||||
|
'w': {0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A, 0x00},
|
||||||
|
'x': {0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x00},
|
||||||
|
'y': {0x00, 0x00, 0x11, 0x11, 0x0F, 0x01, 0x0E, 0x00},
|
||||||
|
'z': {0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F, 0x00},
|
||||||
|
'0': {0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E, 0x00},
|
||||||
|
'1': {0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00},
|
||||||
|
'2': {0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F, 0x00},
|
||||||
|
'3': {0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E, 0x00},
|
||||||
|
'4': {0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02, 0x00},
|
||||||
|
'5': {0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E, 0x00},
|
||||||
|
'6': {0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E, 0x00},
|
||||||
|
'7': {0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08, 0x00},
|
||||||
|
'8': {0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E, 0x00},
|
||||||
|
'9': {0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C, 0x00},
|
||||||
|
' ': {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||||
|
'.': {0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00},
|
||||||
|
',': {0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x02, 0x04},
|
||||||
|
'-': {0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 0x00},
|
||||||
|
'_': {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x00},
|
||||||
|
':': {0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00, 0x00},
|
||||||
|
'/': {0x01, 0x02, 0x02, 0x04, 0x08, 0x08, 0x10, 0x00},
|
||||||
|
'@': {0x0E, 0x11, 0x17, 0x15, 0x17, 0x10, 0x0E, 0x00},
|
||||||
|
'(': {0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02, 0x00},
|
||||||
|
')': {0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08, 0x00},
|
||||||
|
}
|
||||||
|
|
||||||
|
if bitmap, ok := fonts[ch]; ok {
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
// Return a small square for unknown characters
|
||||||
|
return []byte{0x00, 0x1F, 0x11, 0x11, 0x11, 0x1F, 0x00, 0x00}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatermarkDOCX adds a text watermark to a Word document.
|
||||||
|
// DOCX is a ZIP file; we unzip, modify word/document.xml to add a watermark shape in the header, and rezip.
|
||||||
|
func WatermarkDOCX(in []byte, label string) ([]byte, error) {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty input")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bytes.NewReader(in)
|
||||||
|
zipReader, err := zip.NewReader(reader, int64(len(in)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open zip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
zipWriter := zip.NewWriter(&out)
|
||||||
|
|
||||||
|
// Track which relationship files we've seen
|
||||||
|
var hasHeader1 bool
|
||||||
|
headerRelId := "rIdWatermarkHeader"
|
||||||
|
|
||||||
|
// First pass: check if header1.xml exists
|
||||||
|
for _, f := range zipReader.File {
|
||||||
|
if f.Name == "word/header1.xml" {
|
||||||
|
hasHeader1 = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range zipReader.File {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify content based on file
|
||||||
|
switch f.Name {
|
||||||
|
case "word/document.xml":
|
||||||
|
// Add header reference to document if not already present
|
||||||
|
if !hasHeader1 {
|
||||||
|
content = addHeaderReferenceToDocument(content, headerRelId)
|
||||||
|
}
|
||||||
|
case "word/header1.xml":
|
||||||
|
// Add watermark to existing header
|
||||||
|
content = addWatermarkToHeader(content, label)
|
||||||
|
case "word/_rels/document.xml.rels":
|
||||||
|
// Add relationship for header if we created one
|
||||||
|
if !hasHeader1 {
|
||||||
|
content = addHeaderRelationship(content, headerRelId)
|
||||||
|
}
|
||||||
|
case "[Content_Types].xml":
|
||||||
|
// Ensure header content type is registered
|
||||||
|
if !hasHeader1 {
|
||||||
|
content = ensureHeaderContentType(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write modified content
|
||||||
|
w, err := zipWriter.Create(f.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(content); err != nil {
|
||||||
|
return nil, fmt.Errorf("write %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no header existed, create one with the watermark
|
||||||
|
if !hasHeader1 {
|
||||||
|
header := createWatermarkHeader(label)
|
||||||
|
w, err := zipWriter.Create("word/header1.xml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create header: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(header); err != nil {
|
||||||
|
return nil, fmt.Errorf("write header: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("close zip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWatermarkHeader creates a new header XML with a diagonal watermark shape.
|
||||||
|
func createWatermarkHeader(label string) []byte {
|
||||||
|
// VML shape for diagonal watermark text
|
||||||
|
// The shape uses a text path to render the watermark diagonally
|
||||||
|
header := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||||
|
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||||
|
xmlns:w10="urn:schemas-microsoft-com:office:word">
|
||||||
|
<w:p>
|
||||||
|
<w:pPr>
|
||||||
|
<w:pStyle w:val="Header"/>
|
||||||
|
</w:pPr>
|
||||||
|
<w:r>
|
||||||
|
<w:pict>
|
||||||
|
<v:shapetype id="_x0000_t136" coordsize="21600,21600" o:spt="136" adj="10800" path="m@7,l@8,m@5,21600l@6,21600e">
|
||||||
|
<v:formulas>
|
||||||
|
<v:f eqn="sum #0 0 10800"/>
|
||||||
|
<v:f eqn="prod #0 2 1"/>
|
||||||
|
<v:f eqn="sum 21600 0 @1"/>
|
||||||
|
<v:f eqn="sum 0 0 @2"/>
|
||||||
|
<v:f eqn="sum 21600 0 @3"/>
|
||||||
|
<v:f eqn="if @0 @3 0"/>
|
||||||
|
<v:f eqn="if @0 21600 @1"/>
|
||||||
|
<v:f eqn="if @0 0 @2"/>
|
||||||
|
<v:f eqn="if @0 @4 21600"/>
|
||||||
|
<v:f eqn="mid @5 @6"/>
|
||||||
|
<v:f eqn="mid @8 @5"/>
|
||||||
|
<v:f eqn="mid @7 @8"/>
|
||||||
|
<v:f eqn="mid @6 @7"/>
|
||||||
|
<v:f eqn="sum @6 0 @5"/>
|
||||||
|
</v:formulas>
|
||||||
|
<v:path textpathok="t" o:connecttype="custom" o:connectlocs="@9,0;@10,10800;@11,21600;@12,10800" o:connectangles="270,180,90,0"/>
|
||||||
|
<v:textpath on="t" fitshape="t"/>
|
||||||
|
<v:handles>
|
||||||
|
<v:h position="#0,bottomRight" xrange="6629,14971"/>
|
||||||
|
</v:handles>
|
||||||
|
<o:lock v:ext="edit" text="t" shapetype="t"/>
|
||||||
|
</v:shapetype>
|
||||||
|
<v:shape id="PowerPlusWaterMarkObject" o:spid="_x0000_s2049" type="#_x0000_t136"
|
||||||
|
style="position:absolute;margin-left:0;margin-top:0;width:527.85pt;height:131.95pt;rotation:315;z-index:-251658752;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin"
|
||||||
|
o:allowincell="f" fillcolor="silver" stroked="f">
|
||||||
|
<v:fill opacity=".5"/>
|
||||||
|
<v:textpath style="font-family:"Calibri";font-size:1pt" string="%s"/>
|
||||||
|
<w10:wrap anchorx="margin" anchory="margin"/>
|
||||||
|
</v:shape>
|
||||||
|
</w:pict>
|
||||||
|
</w:r>
|
||||||
|
</w:p>
|
||||||
|
</w:hdr>`, escapeXML(label))
|
||||||
|
return []byte(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addWatermarkToHeader adds a watermark shape to an existing header.
|
||||||
|
func addWatermarkToHeader(content []byte, label string) []byte {
|
||||||
|
// Insert watermark shape before </w:hdr>
|
||||||
|
watermarkShape := fmt.Sprintf(`
|
||||||
|
<w:p>
|
||||||
|
<w:r>
|
||||||
|
<w:pict xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w10="urn:schemas-microsoft-com:office:word">
|
||||||
|
<v:shapetype id="_x0000_t136" coordsize="21600,21600" o:spt="136" path="m@7,l@8,m@5,21600l@6,21600e">
|
||||||
|
<v:textpath on="t" fitshape="t"/>
|
||||||
|
</v:shapetype>
|
||||||
|
<v:shape type="#_x0000_t136"
|
||||||
|
style="position:absolute;margin-left:0;margin-top:0;width:500pt;height:130pt;rotation:315;z-index:-251658752;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin"
|
||||||
|
fillcolor="silver" stroked="f">
|
||||||
|
<v:fill opacity=".5"/>
|
||||||
|
<v:textpath style="font-family:Calibri;font-size:1pt" string="%s"/>
|
||||||
|
<w10:wrap anchorx="margin" anchory="margin"/>
|
||||||
|
</v:shape>
|
||||||
|
</w:pict>
|
||||||
|
</w:r>
|
||||||
|
</w:p>`, escapeXML(label))
|
||||||
|
|
||||||
|
// Find </w:hdr> and insert before it
|
||||||
|
str := string(content)
|
||||||
|
if idx := strings.LastIndex(str, "</w:hdr>"); idx != -1 {
|
||||||
|
str = str[:idx] + watermarkShape + str[idx:]
|
||||||
|
}
|
||||||
|
return []byte(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addHeaderReferenceToDocument adds a header reference to the document.xml.
|
||||||
|
func addHeaderReferenceToDocument(content []byte, relId string) []byte {
|
||||||
|
str := string(content)
|
||||||
|
|
||||||
|
// Find the sectPr element and add headerReference
|
||||||
|
// Look for <w:sectPr or create one before </w:body>
|
||||||
|
headerRef := fmt.Sprintf(`<w:headerReference w:type="default" r:id="%s"/>`, relId)
|
||||||
|
|
||||||
|
sectPrPattern := regexp.MustCompile(`(<w:sectPr[^>]*>)`)
|
||||||
|
if sectPrPattern.MatchString(str) {
|
||||||
|
str = sectPrPattern.ReplaceAllString(str, "${1}"+headerRef)
|
||||||
|
} else {
|
||||||
|
// No sectPr exists, add one before </w:body>
|
||||||
|
sectPr := fmt.Sprintf(`<w:sectPr xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">%s</w:sectPr>`, headerRef)
|
||||||
|
if idx := strings.LastIndex(str, "</w:body>"); idx != -1 {
|
||||||
|
str = str[:idx] + sectPr + str[idx:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure r namespace is declared
|
||||||
|
if !strings.Contains(str, `xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`) {
|
||||||
|
str = strings.Replace(str, `<w:document `, `<w:document xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" `, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addHeaderRelationship adds the header relationship to document.xml.rels.
|
||||||
|
func addHeaderRelationship(content []byte, relId string) []byte {
|
||||||
|
str := string(content)
|
||||||
|
rel := fmt.Sprintf(`<Relationship Id="%s" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>`, relId)
|
||||||
|
|
||||||
|
// Insert before </Relationships>
|
||||||
|
if idx := strings.LastIndex(str, "</Relationships>"); idx != -1 {
|
||||||
|
str = str[:idx] + rel + str[idx:]
|
||||||
|
}
|
||||||
|
return []byte(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureHeaderContentType ensures the header content type is in [Content_Types].xml.
|
||||||
|
func ensureHeaderContentType(content []byte) []byte {
|
||||||
|
str := string(content)
|
||||||
|
if strings.Contains(str, "header1.xml") {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
override := `<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>`
|
||||||
|
if idx := strings.LastIndex(str, "</Types>"); idx != -1 {
|
||||||
|
str = str[:idx] + override + str[idx:]
|
||||||
|
}
|
||||||
|
return []byte(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeXML escapes special XML characters.
|
||||||
|
func escapeXML(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, "'", "'")
|
||||||
|
s = strings.ReplaceAll(s, "\"", """)
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
{{define "answer_approved.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Your answer was approved ✓</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<!-- Success Icon -->
|
||||||
|
<div style="text-align: center; margin-bottom: 25px;">
|
||||||
|
<div style="display: inline-block; width: 60px; height: 60px; background-color: #dcfce7; border-radius: 50%; line-height: 60px; font-size: 28px;">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600; text-align: center;">
|
||||||
|
Your answer was approved
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
Great news! Your answer for <strong>{{.RequestTitle}}</strong> has been approved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{if .Published}}
|
||||||
|
<div style="background-color: #dcfce7; border-radius: 6px; padding: 15px 20px; margin: 25px 0;">
|
||||||
|
<p style="margin: 0; color: #166534; font-size: 14px;">
|
||||||
|
📁 <strong>Published to Data Room</strong> — Your response is now visible to authorized buyers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .DataRoomURL}}
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||||
|
<a href="{{.DataRoomURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View in Data Room</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p style="margin: 20px 0 0 0; color: #4a5568; font-size: 15px; line-height: 1.6;">
|
||||||
|
Thank you for your prompt response. Keep up the excellent work!
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||||
|
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
{{define "answer_rejected.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Your answer needs revision</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||||
|
Your answer needs revision
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
Your answer for <strong>{{.RequestTitle}}</strong> requires some changes before it can be approved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Feedback Box -->
|
||||||
|
{{if .Reason}}
|
||||||
|
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 20px; margin: 25px 0;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #991b1b; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;">Feedback</p>
|
||||||
|
<p style="margin: 0; color: #7f1d1d; font-size: 15px; line-height: 1.5;">{{.Reason}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||||
|
<a href="{{.RequestURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View Feedback</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 20px 0 0 0; color: #4a5568; font-size: 15px; line-height: 1.6;">
|
||||||
|
Please update your response based on the feedback above. If you have any questions, you can reply directly in the request thread.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||||
|
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
{{define "answer_submitted.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.AnswererName}} submitted an answer for: {{.RequestTitle}}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<div style="display: inline-block; background-color: #fef3c7; color: #92400e; padding: 6px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-bottom: 20px;">
|
||||||
|
ACTION REQUIRED
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||||
|
New answer submitted for review
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
<strong>{{.AnswererName}}</strong> has submitted an answer that needs your review.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Request Details -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f8f9fa; border-radius: 6px; margin: 0 0 25px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Request</p>
|
||||||
|
<p style="margin: 0 0 15px 0; color: #1a2744; font-size: 16px; font-weight: 600;">{{.RequestTitle}}</p>
|
||||||
|
|
||||||
|
{{if .WorkstreamName}}
|
||||||
|
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Workstream</p>
|
||||||
|
<p style="margin: 0 0 15px 0; color: #4a5568; font-size: 14px;">{{.WorkstreamName}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .AnswerPreview}}
|
||||||
|
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Preview</p>
|
||||||
|
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5; font-style: italic;">"{{truncate .AnswerPreview 200}}"</p>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||||
|
<a href="{{.ReviewURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">Review Answer</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 14px;">
|
||||||
|
Once approved, this answer will be published to the data room and visible to authorized buyers.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||||
|
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
{{define "invite.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>You're invited to {{.ProjectName}}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||||
|
You've been invited to join {{.ProjectName}}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
<strong>{{.InviterName}}</strong> from <strong>{{.InviterOrg}}</strong> has invited you to join the due diligence process for <strong>{{.ProjectName}}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||||
|
<a href="{{.InviteURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">Accept Invitation</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- What is Dealspace -->
|
||||||
|
<div style="background-color: #f8f9fa; border-radius: 6px; padding: 20px; margin: 30px 0;">
|
||||||
|
<h3 style="margin: 0 0 10px 0; color: #1a2744; font-size: 14px; font-weight: 600;">What is Dealspace?</h3>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">
|
||||||
|
Dealspace is a secure platform for managing M&A due diligence. All documents are encrypted and watermarked. You control what gets shared and when.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 14px;">
|
||||||
|
⏱ This invitation expires in {{if .ExpiresIn}}{{.ExpiresIn}}{{else}}7 days{{end}}.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||||
|
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
{{define "request_forwarded.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.SenderName}} forwarded a request to you</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||||
|
Request forwarded to you
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
<strong>{{.SenderName}}</strong> has forwarded a request to you for your input.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Request Details -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f8f9fa; border-radius: 6px; margin: 0 0 25px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Request</p>
|
||||||
|
<p style="margin: 0; color: #1a2744; font-size: 16px; font-weight: 600;">{{.RequestTitle}}</p>
|
||||||
|
|
||||||
|
{{if .HasDueDate}}
|
||||||
|
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0 0 4px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Due Date</p>
|
||||||
|
<p style="margin: 0; color: #dc2626; font-size: 14px; font-weight: 500;">{{.DueDate}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||||
|
<a href="{{.RequestURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View Request</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 14px;">
|
||||||
|
You can respond to this request directly in Dealspace. Your response will be routed back to {{.SenderName}} for review.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||||
|
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
{{define "tasks_assigned.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>You have {{.Count}} new task{{if gt .Count 1}}s{{end}} on {{.ProjectName}}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||||
|
You have {{.Count}} new task{{if gt .Count 1}}s{{end}} on {{.ProjectName}}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||||
|
The following request{{if gt .Count 1}}s have{{else}} has{{end}} been assigned to you:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Task List -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 0 0 25px 0;">
|
||||||
|
{{range $i, $task := .Tasks}}
|
||||||
|
{{if lt $i 5}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px 15px; border-left: 3px solid {{if eq $task.Priority "high"}}#dc2626{{else}}#c9a227{{end}}; background-color: #f8f9fa; margin-bottom: 8px;">
|
||||||
|
<p style="margin: 0; color: #1a2744; font-size: 15px; font-weight: 500;">{{$task.Title}}</p>
|
||||||
|
{{if $task.DueDate}}
|
||||||
|
<p style="margin: 4px 0 0 0; color: #6b7280; font-size: 13px;">Due: {{$task.DueDate}}{{if eq $task.Priority "high"}} · <span style="color: #dc2626;">High Priority</span>{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td style="height: 8px;"></td></tr>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{if gt .Count 5}}
|
||||||
|
<p style="margin: 0 0 25px 0; color: #6b7280; font-size: 14px;">
|
||||||
|
...and {{sub .Count 5}} more
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||||
|
<a href="{{.TasksURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View My Tasks</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||||
|
You're receiving this because you're assigned to requests in {{.ProjectName}}.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||||
|
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Manage Notifications</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Unsubscribe</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>My Tasks — Dealspace</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
* { font-family: 'Inter', sans-serif; }
|
||||||
|
body { background: #0a1628; margin: 0; }
|
||||||
|
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
|
||||||
|
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
|
||||||
|
.task-card:hover { border-color: rgba(201,168,76,0.3); transform: translateY(-1px); }
|
||||||
|
.priority-high { background: #ef4444; }
|
||||||
|
.priority-normal { background: #c9a84c; }
|
||||||
|
.priority-low { background: #22c55e; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Top Nav -->
|
||||||
|
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight">
|
||||||
|
<span class="text-[#c9a84c]">Deal</span>space
|
||||||
|
</a>
|
||||||
|
<select id="projectSwitcher" class="bg-[#0a1628] border border-white/[0.08] rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-[#c9a84c]">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span id="userName" class="text-sm text-[#94a3b8]"></span>
|
||||||
|
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
|
||||||
|
<div class="p-3 space-y-0.5">
|
||||||
|
<a href="/app/tasks" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
|
||||||
|
<svg class="w-4 h-4 text-[#94a3b8]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||||
|
My Tasks
|
||||||
|
</a>
|
||||||
|
<a href="/app/projects" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||||
|
Projects
|
||||||
|
</a>
|
||||||
|
<div id="adminLinks" class="hidden">
|
||||||
|
<div class="border-t border-white/[0.08] my-3"></div>
|
||||||
|
<a href="/app/projects" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 p-8 max-w-5xl">
|
||||||
|
<!-- Greeting -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 id="greeting" class="text-2xl font-bold text-white mb-1"></h1>
|
||||||
|
<p class="text-[#94a3b8] text-sm">Here are your pending tasks.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task List -->
|
||||||
|
<div id="taskList" class="space-y-3">
|
||||||
|
<div class="text-[#94a3b8] text-sm">Loading tasks...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div id="emptyState" class="hidden text-center py-20">
|
||||||
|
<div class="text-5xl mb-4">🎉</div>
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-2">You're all caught up</h2>
|
||||||
|
<p class="text-[#94a3b8]">No tasks need your attention right now.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auth check
|
||||||
|
const token = localStorage.getItem('ds_token');
|
||||||
|
if (!token) { window.location.href = '/app/login'; }
|
||||||
|
|
||||||
|
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
|
||||||
|
|
||||||
|
function fetchAPI(path, opts = {}) {
|
||||||
|
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||||
|
return fetch(path, opts).then(r => {
|
||||||
|
if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; }
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => {
|
||||||
|
localStorage.removeItem('ds_token');
|
||||||
|
localStorage.removeItem('ds_user');
|
||||||
|
window.location.href = '/app/login';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Greeting
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const greetWord = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||||
|
document.getElementById('greeting').textContent = greetWord + ', ' + (user.name || 'there');
|
||||||
|
document.getElementById('userName').textContent = user.name || user.email || '';
|
||||||
|
if (user.role === 'ib_admin') document.getElementById('adminLinks').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load tasks
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
const res = await fetchAPI('/api/tasks');
|
||||||
|
const tasks = await res.json();
|
||||||
|
const list = document.getElementById('taskList');
|
||||||
|
const empty = document.getElementById('emptyState');
|
||||||
|
|
||||||
|
if (!tasks || tasks.length === 0) {
|
||||||
|
list.classList.add('hidden');
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = tasks.map(t => {
|
||||||
|
const data = parseData(t.data_text);
|
||||||
|
const priority = data.priority || 'normal';
|
||||||
|
const title = data.title || t.summary || 'Untitled';
|
||||||
|
const ref = data.ref || '';
|
||||||
|
const due = data.due_date || '';
|
||||||
|
const status = data.status || 'open';
|
||||||
|
const projectName = t.project_id ? t.project_id.substring(0, 8) : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="/app/requests/${t.entry_id}" class="task-card block bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5 transition cursor-pointer">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full priority-${priority} shrink-0"></span>
|
||||||
|
${ref ? `<span class="text-xs font-mono text-[#94a3b8]">${ref}</span>` : ''}
|
||||||
|
<span class="text-white font-medium flex-1">${escapeHtml(title)}</span>
|
||||||
|
${due ? `<span class="text-xs text-[#94a3b8]">Due: ${due}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-[#475569]">
|
||||||
|
<span class="capitalize px-2 py-0.5 rounded-full bg-white/[0.05] text-[#94a3b8]">${status}</span>
|
||||||
|
<span>${t.type || 'request'}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('taskList').innerHTML = '<div class="text-red-400 text-sm">Failed to load tasks.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseData(text) {
|
||||||
|
if (!text) return {};
|
||||||
|
try { return JSON.parse(text); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTasks();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login — Dealspace</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
* { font-family: 'Inter', sans-serif; }
|
||||||
|
body { background: #0a1628; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md px-6">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="text-center mb-10">
|
||||||
|
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||||
|
<span class="text-[#c9a84c]">Deal</span>space
|
||||||
|
</h1>
|
||||||
|
<p class="text-[#94a3b8] mt-2 text-sm">Secure M&A deal management</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Card -->
|
||||||
|
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-6">Sign in</h2>
|
||||||
|
|
||||||
|
<div id="error" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
|
||||||
|
|
||||||
|
<form id="loginForm" class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="email" autofocus
|
||||||
|
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||||
|
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="submitBtn"
|
||||||
|
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-[#475569] text-xs mt-8">© 2026 Muskepo B.V. — Amsterdam</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// If already logged in, redirect
|
||||||
|
if (localStorage.getItem('ds_token')) {
|
||||||
|
window.location.href = '/app/tasks';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Signing in...';
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: document.getElementById('email').value,
|
||||||
|
password: document.getElementById('password').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('ds_token', data.token);
|
||||||
|
localStorage.setItem('ds_user', JSON.stringify(data.user));
|
||||||
|
window.location.href = '/app/tasks';
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Sign in';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Setup — Dealspace</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
* { font-family: 'Inter', sans-serif; }
|
||||||
|
body { background: #0a1628; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md px-6">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="text-center mb-10">
|
||||||
|
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||||
|
<span class="text-[#c9a84c]">Deal</span>space
|
||||||
|
</h1>
|
||||||
|
<p class="text-[#94a3b8] mt-2 text-sm">First-time setup</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Setup Card -->
|
||||||
|
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-2">Create admin account</h2>
|
||||||
|
<p class="text-[#94a3b8] text-sm mb-6">This will be the first administrator account for your Dealspace instance.</p>
|
||||||
|
|
||||||
|
<div id="error" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
|
||||||
|
<div id="success" class="hidden mb-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg text-green-400 text-sm"></div>
|
||||||
|
|
||||||
|
<form id="setupForm" class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Full name</label>
|
||||||
|
<input type="text" id="name" name="name" required autofocus
|
||||||
|
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="email"
|
||||||
|
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password"
|
||||||
|
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||||
|
<p class="text-xs text-[#475569] mt-1">Minimum 8 characters</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="submitBtn"
|
||||||
|
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
|
||||||
|
Create account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-[#475569] text-xs mt-8">© 2026 Muskepo B.V. — Amsterdam</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('setupForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
const successEl = document.getElementById('success');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Creating...';
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
successEl.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: document.getElementById('name').value,
|
||||||
|
email: document.getElementById('email').value,
|
||||||
|
password: document.getElementById('password').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Setup failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
successEl.textContent = 'Admin account created! Redirecting to login...';
|
||||||
|
successEl.classList.remove('hidden');
|
||||||
|
setTimeout(() => { window.location.href = '/app/login'; }, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Create account';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -352,6 +352,7 @@
|
||||||
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
||||||
|
<li><a href="soc2.html" class="hover:text-white transition-colors">SOC 2</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,7 @@
|
||||||
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
||||||
|
<li><a href="soc2.html" class="hover:text-white transition-colors">SOC 2</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -546,6 +546,7 @@
|
||||||
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
||||||
|
<li><a href="soc2.html" class="hover:text-white transition-colors">SOC 2</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,7 @@
|
||||||
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
||||||
|
<li><a href="soc2.html" class="hover:text-white transition-colors">SOC 2</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,7 @@
|
||||||
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
||||||
|
<li><a href="soc2.html" class="hover:text-white transition-colors">SOC 2</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -96,16 +96,16 @@
|
||||||
<section class="py-16 px-6 bg-navy-light border-b border-white/10">
|
<section class="py-16 px-6 bg-navy-light border-b border-white/10">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||||
<div class="text-center">
|
<a href="soc2.html" class="text-center block hover:opacity-80 transition-opacity">
|
||||||
<div class="w-20 h-20 mx-auto mb-4 rounded-xl bg-navy border border-gold/30 flex items-center justify-center">
|
<div class="w-20 h-20 mx-auto mb-4 rounded-xl bg-navy border border-gold/30 flex items-center justify-center">
|
||||||
<svg viewBox="0 0 60 60" class="w-12 h-12" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 60 60" class="w-12 h-12" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M30 5 L50 15 L50 30 C50 42 40 52 30 55 C20 52 10 42 10 30 L10 15 Z" fill="none" stroke="#C9A84C" stroke-width="2"/>
|
<path d="M30 5 L50 15 L50 30 C50 42 40 52 30 55 C20 52 10 42 10 30 L10 15 Z" fill="none" stroke="#C9A84C" stroke-width="2"/>
|
||||||
<path d="M22 30 L27 35 L38 24" stroke="#C9A84C" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M22 30 L27 35 L38 24" stroke="#C9A84C" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="font-semibold text-white">SOC 2 Type II</h3>
|
<h3 class="font-semibold text-white">SOC 2</h3>
|
||||||
<p class="text-sm text-gray-400 mt-1">Certified compliant</p>
|
<p class="text-sm text-gray-400 mt-1">Self-Assessed · Type II in progress</p>
|
||||||
</div>
|
</a>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="w-20 h-20 mx-auto mb-4 rounded-xl bg-navy border border-gold/30 flex items-center justify-center">
|
<div class="w-20 h-20 mx-auto mb-4 rounded-xl bg-navy border border-gold/30 flex items-center justify-center">
|
||||||
<svg viewBox="0 0 60 60" class="w-12 h-12" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 60 60" class="w-12 h-12" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
@ -563,6 +563,7 @@
|
||||||
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
||||||
|
<li><a href="soc2.html" class="hover:text-white transition-colors">SOC 2</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,679 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SOC 2 Compliance — Dealspace</title>
|
||||||
|
<meta name="description" content="SOC 2 Type II self-assessment documentation. Trust Services Criteria coverage for Security, Availability, Confidentiality, Processing Integrity, and Privacy.">
|
||||||
|
<!-- OpenGraph -->
|
||||||
|
<meta property="og:title" content="SOC 2 Compliance — Dealspace">
|
||||||
|
<meta property="og:description" content="SOC 2 Type II self-assessment documentation. Trust Services Criteria coverage for Security, Availability, Confidentiality, Processing Integrity, and Privacy.">
|
||||||
|
<meta property="og:url" content="https://muskepo.com/soc2">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:image" content="https://muskepo.com/og-image.png">
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="SOC 2 Compliance — Dealspace">
|
||||||
|
<meta name="twitter:description" content="SOC 2 Type II self-assessment documentation. Trust Services Criteria coverage for Security, Availability, Confidentiality, Processing Integrity, and Privacy.">
|
||||||
|
<meta name="twitter:image" content="https://muskepo.com/og-image.png">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
navy: '#0F1B35',
|
||||||
|
'navy-light': '#1a2847',
|
||||||
|
slate: '#2B4680',
|
||||||
|
gold: '#C9A84C',
|
||||||
|
'gold-light': '#d4b85f',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #C9A84C 0%, #d4b85f 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-navy font-sans text-white antialiased">
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="fixed top-0 left-0 right-0 z-50 bg-navy/95 backdrop-blur-sm border-b border-white/10">
|
||||||
|
<div class="max-w-7xl mx-auto px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a href="index.html" class="flex items-center space-x-2">
|
||||||
|
<span class="text-2xl font-bold text-white">Deal<span class="text-gold">space</span></span>
|
||||||
|
</a>
|
||||||
|
<div class="hidden md:flex items-center space-x-8">
|
||||||
|
<a href="features.html" class="text-gray-300 hover:text-white transition-colors">Features</a>
|
||||||
|
<a href="security.html" class="text-gray-300 hover:text-white transition-colors">Security</a>
|
||||||
|
<a href="pricing.html" class="text-gray-300 hover:text-white transition-colors">Pricing</a>
|
||||||
|
<a href="#" class="text-gray-300 hover:text-white transition-colors">Sign In</a>
|
||||||
|
<a href="index.html#demo" class="bg-gold hover:bg-gold-light text-navy font-semibold px-5 py-2.5 rounded-lg transition-colors">Request Demo</a>
|
||||||
|
</div>
|
||||||
|
<button class="md:hidden text-white" aria-label="Toggle mobile menu" onclick="document.getElementById('mobile-menu').classList.toggle('hidden')">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-menu" class="hidden md:hidden pt-4 pb-2 space-y-3">
|
||||||
|
<a href="features.html" class="block text-gray-300 hover:text-white">Features</a>
|
||||||
|
<a href="security.html" class="block text-gray-300 hover:text-white">Security</a>
|
||||||
|
<a href="pricing.html" class="block text-gray-300 hover:text-white">Pricing</a>
|
||||||
|
<a href="#" class="block text-gray-300 hover:text-white">Sign In</a>
|
||||||
|
<a href="index.html#demo" class="inline-block bg-gold text-navy font-semibold px-5 py-2.5 rounded-lg mt-2">Request Demo</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="pt-32 pb-16 px-6 border-b border-white/10">
|
||||||
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
|
<div class="inline-block bg-yellow-500/20 text-yellow-400 text-sm font-medium px-4 py-2 rounded-full mb-6">
|
||||||
|
Self-Assessment · Type II Audit Planned Q4 2026
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl md:text-5xl font-bold mb-6">
|
||||||
|
SOC 2 <span class="gradient-text">Compliance</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-400 max-w-2xl mx-auto">
|
||||||
|
Dealspace has completed a comprehensive SOC 2 Type II self-assessment. We are preparing for formal audit certification in Q4 2026.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Disclaimer Banner -->
|
||||||
|
<section class="py-6 px-6 bg-yellow-500/10 border-b border-yellow-500/20">
|
||||||
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
|
<p class="text-yellow-200">
|
||||||
|
<strong>Note:</strong> This is a self-assessment document. Formal SOC 2 Type II audit is planned for Q4 2026.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
<section class="py-24 px-6">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<div>
|
||||||
|
<div class="inline-block bg-gold/20 text-gold text-sm font-medium px-3 py-1 rounded-full mb-6">Overview</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold mb-6">What is SOC 2?</h2>
|
||||||
|
<p class="text-gray-400 text-lg mb-6 leading-relaxed">
|
||||||
|
SOC 2 (System and Organization Controls 2) is an auditing framework developed by the AICPA that evaluates how organizations manage customer data based on five Trust Services Criteria.
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-400 text-lg leading-relaxed">
|
||||||
|
For M&A platforms handling confidential deal data, SOC 2 compliance demonstrates a commitment to security, availability, and data protection that investment banks and advisors require.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-navy-light border border-white/10 rounded-xl p-8">
|
||||||
|
<h3 class="font-semibold text-white text-xl mb-6">Self-Assessment Summary</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-300">Security (CC1-CC9)</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-32 h-2 bg-navy rounded-full mr-3">
|
||||||
|
<div class="w-[95%] h-full bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-green-400 font-medium">95%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-300">Availability (A1)</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-32 h-2 bg-navy rounded-full mr-3">
|
||||||
|
<div class="w-[95%] h-full bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-green-400 font-medium">95%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-300">Confidentiality (C1)</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-32 h-2 bg-navy rounded-full mr-3">
|
||||||
|
<div class="w-[98%] h-full bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-green-400 font-medium">98%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-300">Processing Integrity (PI1)</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-32 h-2 bg-navy rounded-full mr-3">
|
||||||
|
<div class="w-[95%] h-full bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-green-400 font-medium">95%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-300">Privacy (P1-P8)</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-32 h-2 bg-navy rounded-full mr-3">
|
||||||
|
<div class="w-[95%] h-full bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-green-400 font-medium">95%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 pt-6 border-t border-white/10">
|
||||||
|
<p class="text-gray-400 text-sm">Assessment Date: February 28, 2026</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Scope -->
|
||||||
|
<section class="py-24 px-6 bg-navy-light">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<div class="inline-block bg-gold/20 text-gold text-sm font-medium px-3 py-1 rounded-full mb-6">Scope</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold mb-6">What's Covered</h2>
|
||||||
|
<p class="text-xl text-gray-400 max-w-3xl mx-auto">
|
||||||
|
Our SOC 2 assessment covers all aspects of the Dealspace platform and infrastructure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-8">
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-8">
|
||||||
|
<div class="w-14 h-14 bg-slate/30 rounded-lg flex items-center justify-center mb-6">
|
||||||
|
<svg class="w-7 h-7 text-gold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Infrastructure</h3>
|
||||||
|
<ul class="text-gray-400 space-y-2">
|
||||||
|
<li>• Production server (Zürich, Switzerland)</li>
|
||||||
|
<li>• Go application binary</li>
|
||||||
|
<li>• SQLite encrypted database</li>
|
||||||
|
<li>• Caddy reverse proxy</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-8">
|
||||||
|
<div class="w-14 h-14 bg-slate/30 rounded-lg flex items-center justify-center mb-6">
|
||||||
|
<svg class="w-7 h-7 text-gold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Data Types</h3>
|
||||||
|
<ul class="text-gray-400 space-y-2">
|
||||||
|
<li>• M&A deal documents</li>
|
||||||
|
<li>• Financial data</li>
|
||||||
|
<li>• Transaction details</li>
|
||||||
|
<li>• Participant information</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-8">
|
||||||
|
<div class="w-14 h-14 bg-slate/30 rounded-lg flex items-center justify-center mb-6">
|
||||||
|
<svg class="w-7 h-7 text-gold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-3">User Types</h3>
|
||||||
|
<ul class="text-gray-400 space-y-2">
|
||||||
|
<li>• Investment bank admins/members</li>
|
||||||
|
<li>• Seller organizations</li>
|
||||||
|
<li>• Buyer organizations</li>
|
||||||
|
<li>• Observers</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Trust Services Criteria -->
|
||||||
|
<section class="py-24 px-6">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<div class="inline-block bg-gold/20 text-gold text-sm font-medium px-3 py-1 rounded-full mb-6">Trust Services Criteria</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold mb-6">The Five Pillars</h2>
|
||||||
|
<p class="text-xl text-gray-400 max-w-3xl mx-auto">
|
||||||
|
SOC 2 evaluates organizations against five Trust Services Criteria. Dealspace implements controls for all five.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Security -->
|
||||||
|
<div class="bg-navy-light border border-white/10 rounded-xl p-8">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="w-14 h-14 bg-blue-500/20 rounded-lg flex items-center justify-center mr-6 flex-shrink-0">
|
||||||
|
<svg class="w-7 h-7 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Security (CC1-CC9)</h3>
|
||||||
|
<p class="text-gray-400 mb-4">Protection against unauthorized access, both physical and logical.</p>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
FIPS 140-3 encryption (AES-256-GCM)
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Per-project key derivation (HKDF-SHA256)
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Role-based access control (RBAC)
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
MFA required for IB users
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Availability -->
|
||||||
|
<div class="bg-navy-light border border-white/10 rounded-xl p-8">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="w-14 h-14 bg-green-500/20 rounded-lg flex items-center justify-center mr-6 flex-shrink-0">
|
||||||
|
<svg class="w-7 h-7 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Availability (A1)</h3>
|
||||||
|
<p class="text-gray-400 mb-4">Systems are available for operation and use as committed.</p>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
99.9% uptime SLA
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
4-hour recovery time objective
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Daily encrypted backups
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Swiss data center (Zürich)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confidentiality -->
|
||||||
|
<div class="bg-navy-light border border-white/10 rounded-xl p-8">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="w-14 h-14 bg-purple-500/20 rounded-lg flex items-center justify-center mr-6 flex-shrink-0">
|
||||||
|
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Confidentiality (C1)</h3>
|
||||||
|
<p class="text-gray-400 mb-4">Information designated as confidential is protected as committed.</p>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
All deal data encrypted at rest
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Blind indexes for searchable encryption
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
TLS 1.3 for all connections
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Dynamic document watermarking
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing Integrity -->
|
||||||
|
<div class="bg-navy-light border border-white/10 rounded-xl p-8">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="w-14 h-14 bg-orange-500/20 rounded-lg flex items-center justify-center mr-6 flex-shrink-0">
|
||||||
|
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Processing Integrity (PI1)</h3>
|
||||||
|
<p class="text-gray-400 mb-4">System processing is complete, valid, accurate, timely, and authorized.</p>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Input validation on all data
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Parameterized SQL queries
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Optimistic locking (ETag)
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
ACID transaction compliance
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Privacy -->
|
||||||
|
<div class="bg-navy-light border border-white/10 rounded-xl p-8">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="w-14 h-14 bg-pink-500/20 rounded-lg flex items-center justify-center mr-6 flex-shrink-0">
|
||||||
|
<svg class="w-7 h-7 text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Privacy (P1-P8)</h3>
|
||||||
|
<p class="text-gray-400 mb-4">Personal information is collected, used, retained, and disclosed in conformity with commitments.</p>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
GDPR/FADP/CCPA compliant
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Data export on request
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
No third-party tracking
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-300">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
No data sales
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Controls Summary -->
|
||||||
|
<section class="py-24 px-6 bg-navy-light">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<div class="inline-block bg-gold/20 text-gold text-sm font-medium px-3 py-1 rounded-full mb-6">Controls Summary</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold mb-6">Key Security Controls</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-white mb-2">Encryption</h3>
|
||||||
|
<p class="text-gray-400 text-sm">FIPS 140-3 validated AES-256-GCM with per-project keys derived via HKDF-SHA256</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-white mb-2">Authentication</h3>
|
||||||
|
<p class="text-gray-400 text-sm">JWT tokens with 1-hour expiry, MFA required for IB users, session management</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-white mb-2">Authorization</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Role hierarchy (IB → Seller → Buyer → Observer), invitation-only access</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-white mb-2">Infrastructure</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Swiss data center, UFW firewall, SSH key-only, automatic security updates</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-white mb-2">Audit Logging</h3>
|
||||||
|
<p class="text-gray-400 text-sm">All access logged with actor, timestamp, IP. 7-year retention for compliance</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-white mb-2">Backup & Recovery</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Daily encrypted backups, 4-hour RTO, 24-hour RPO, tested recovery procedures</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Policy Documents -->
|
||||||
|
<section class="py-24 px-6">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<div class="inline-block bg-gold/20 text-gold text-sm font-medium px-3 py-1 rounded-full mb-6">Documentation</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold mb-6">Policy Documents</h2>
|
||||||
|
<p class="text-xl text-gray-400 max-w-3xl mx-auto">
|
||||||
|
Our SOC 2 program is supported by comprehensive policy documentation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<a href="/docs/soc2/soc2-self-assessment-2026.md" class="bg-navy-light border border-white/10 rounded-xl p-6 hover:border-gold/50 transition-colors group">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gold mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="font-semibold text-white group-hover:text-gold transition-colors">Self-Assessment Report</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-sm">Complete SOC 2 Type II self-assessment with control mappings</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/docs/soc2/security-policy.md" class="bg-navy-light border border-white/10 rounded-xl p-6 hover:border-gold/50 transition-colors group">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gold mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="font-semibold text-white group-hover:text-gold transition-colors">Security Policy</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-sm">Security requirements for systems, data, and operations</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/docs/soc2/incident-response-plan.md" class="bg-navy-light border border-white/10 rounded-xl p-6 hover:border-gold/50 transition-colors group">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gold mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="font-semibold text-white group-hover:text-gold transition-colors">Incident Response Plan</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-sm">Procedures for detecting and responding to security incidents</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/docs/soc2/disaster-recovery-plan.md" class="bg-navy-light border border-white/10 rounded-xl p-6 hover:border-gold/50 transition-colors group">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gold mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="font-semibold text-white group-hover:text-gold transition-colors">Disaster Recovery Plan</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-sm">Recovery procedures following disasters affecting systems</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/docs/soc2/data-retention-policy.md" class="bg-navy-light border border-white/10 rounded-xl p-6 hover:border-gold/50 transition-colors group">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gold mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="font-semibold text-white group-hover:text-gold transition-colors">Data Retention Policy</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-sm">Data retention periods and deletion procedures</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/docs/soc2/risk-assessment.md" class="bg-navy-light border border-white/10 rounded-xl p-6 hover:border-gold/50 transition-colors group">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gold mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="font-semibold text-white group-hover:text-gold transition-colors">Risk Assessment</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-sm">Identified risks and mitigation controls</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<section class="py-24 px-6 bg-navy-light">
|
||||||
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
|
<div class="inline-block bg-gold/20 text-gold text-sm font-medium px-3 py-1 rounded-full mb-6">Status</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold mb-6">Audit Timeline</h2>
|
||||||
|
|
||||||
|
<div class="bg-navy border border-white/10 rounded-xl p-8 text-left">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="w-8 h-8 bg-green-500/20 rounded-full flex items-center justify-center mr-4 mt-1 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-white">February 2026 — Self-Assessment Complete</h4>
|
||||||
|
<p class="text-gray-400">Comprehensive self-assessment against all five Trust Services Criteria completed. Policy documentation created.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="w-8 h-8 bg-blue-500/20 rounded-full flex items-center justify-center mr-4 mt-1 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-white">Q2 2026 — Gap Remediation</h4>
|
||||||
|
<p class="text-gray-400">Address recommended action items including backup restore testing and external penetration test.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="w-8 h-8 bg-yellow-500/20 rounded-full flex items-center justify-center mr-4 mt-1 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-white">Q4 2026 — Formal SOC 2 Type II Audit</h4>
|
||||||
|
<p class="text-gray-400">Engage third-party auditor for formal SOC 2 Type II certification.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="py-24 px-6 border-t border-white/10">
|
||||||
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold mb-6">Questions About Compliance?</h2>
|
||||||
|
<p class="text-xl text-gray-400 mb-8">
|
||||||
|
Contact our security team for detailed documentation or to discuss your compliance requirements.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<a href="mailto:security@muskepo.com" class="bg-gold hover:bg-gold-light text-navy font-semibold px-8 py-4 rounded-lg transition-colors">
|
||||||
|
Contact Security Team
|
||||||
|
</a>
|
||||||
|
<a href="security.html" class="border border-white/20 hover:border-white/40 text-white font-semibold px-8 py-4 rounded-lg transition-colors">
|
||||||
|
View Security Page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="border-t border-white/10 py-12 px-6">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="grid md:grid-cols-4 gap-8 mb-12">
|
||||||
|
<div>
|
||||||
|
<span class="text-2xl font-bold text-white">Deal<span class="text-gold">space</span></span>
|
||||||
|
<p class="text-gray-400 mt-4">The M&A workflow platform that Investment Banks trust.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-4">Product</h4>
|
||||||
|
<ul class="space-y-2 text-gray-400">
|
||||||
|
<li><a href="features.html" class="hover:text-white transition-colors">Features</a></li>
|
||||||
|
<li><a href="security.html" class="hover:text-white transition-colors">Security</a></li>
|
||||||
|
<li><a href="pricing.html" class="hover:text-white transition-colors">Pricing</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-4">Legal</h4>
|
||||||
|
<ul class="space-y-2 text-gray-400">
|
||||||
|
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
|
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
|
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
||||||
|
<li><a href="soc2.html" class="hover:text-white transition-colors">SOC 2</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-4">Contact</h4>
|
||||||
|
<ul class="space-y-2 text-gray-400">
|
||||||
|
<li><a href="mailto:sales@muskepo.com" class="hover:text-white transition-colors">sales@muskepo.com</a></li>
|
||||||
|
<li><a href="mailto:security@muskepo.com" class="hover:text-white transition-colors">security@muskepo.com</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-white/10 pt-8 flex flex-col md:flex-row justify-between items-center">
|
||||||
|
<p class="text-gray-500 text-sm">© 2026 Muskepo B.V. All rights reserved.</p>
|
||||||
|
<p class="text-gray-500 text-sm mt-4 md:mt-0">Amsterdam · New York · London</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/chat.css">
|
||||||
|
<script src="/chat.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -325,6 +325,7 @@
|
||||||
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
<li><a href="privacy.html" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
<li><a href="terms.html" class="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
<li><a href="dpa.html" class="hover:text-white transition-colors">DPA</a></li>
|
||||||
|
<li><a href="soc2.html" class="hover:text-white transition-colors">SOC 2</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue