Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.PHONY: build fmt check clean all

# Go binaries to build
BINARIES := bash_tool chat edit_tool list_files read
BINARIES := bash_tool chat edit_tool list_files read server

# Build all binaries
build:
Expand All @@ -11,6 +11,7 @@ build:
go build -o edit_tool edit_tool.go
go build -o list_files list_files.go
go build -o read read.go
go build -o server server.go

# Format all Go files
fmt:
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,53 @@ go run code_search_tool.go

## 🛠️ Tool System Architecture

### Authentication & Subscriptions

The server now supports JWT-based authentication and a subscription paywall backed by Stripe.

Endpoints:

- POST `/api/signup`: `{ email, password }` → `{ token, user }`
- POST `/api/login`: `{ email, password }` → `{ token, user }`
- GET `/api/me`: Requires `Authorization: Bearer <token>` → `{ email, subscription_ok }`
- POST `/api/checkout`: Requires auth → returns `{ url }` to Stripe Checkout
- POST `/api/portal`: Requires auth → returns `{ url }` to Stripe Billing Portal
- POST `/api/stripe/webhook`: Stripe event receiver (configure your webhook to point here)

Paywall:

- The chat endpoint `POST /api/message` now requires a valid JWT and an active subscription. It returns HTTP 401 if unauthenticated and 402 if a subscription is required.

Frontend:

- Minimal controls added to the header to login, signup, subscribe, manage billing, and logout. Token is persisted in `localStorage` and sent as `Authorization: Bearer` for API calls.

Environment variables:

- `JWT_SECRET` (required): HMAC secret for signing JWTs
- `USER_STORE_PATH` (optional, default `data/users.json`)
- `STRIPE_SECRET_KEY` (required for billing)
- `STRIPE_PRICE_ID` (required): The Price ID for your subscription product
- `STRIPE_WEBHOOK_SECRET` (recommended): Validates webhook signatures
- `PUBLIC_URL` (optional): Base URL for success/return links, default `/`

Run the server:

```bash
JWT_SECRET=change-me \
STRIPE_SECRET_KEY=sk_test_... \
STRIPE_PRICE_ID=price_... \
STRIPE_WEBHOOK_SECRET=whsec_... \
go build -o server server.go auth.go billing.go && ./server --verbose
```

Stripe webhook (example with Stripe CLI):

```bash
stripe listen --events checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:8080/api/stripe/webhook
```


The tool system uses a consistent pattern across all applications:

```mermaid
Expand Down
183 changes: 183 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"

"golang.org/x/crypto/bcrypt"
)

// User represents an application user.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Roles []string `json:"roles,omitempty"`
SubscriptionOK bool `json:"subscription_ok"`
StripeCustomerID string `json:"stripe_customer_id,omitempty"`
CreatedAt int64 `json:"created_at"`
}

// UserStore provides thread-safe CRUD access to users backed by a JSON file.
type UserStore struct {
filePath string
mu sync.RWMutex
byEmail map[string]*User
}

func NewUserStore(filePath string) (*UserStore, error) {
us := &UserStore{filePath: filePath, byEmail: map[string]*User{}}
if err := us.load(); err != nil {
if errors.Is(err, os.ErrNotExist) {
// Ensure directory exists
_ = os.MkdirAll(filepath.Dir(filePath), 0o755)
if err := us.save(); err != nil { return nil, err }
} else {
return nil, err
}
}
return us, nil
}

func (s *UserStore) load() error {
b, err := os.ReadFile(s.filePath)
if err != nil { return err }
var users []*User
if err := json.Unmarshal(b, &users); err != nil { return err }
for _, u := range users {
s.byEmail[strings.ToLower(u.Email)] = u
}
return nil
}

func (s *UserStore) save() error {
s.mu.RLock()
defer s.mu.RUnlock()
var users []*User
for _, u := range s.byEmail {
users = append(users, u)
}
b, err := json.MarshalIndent(users, "", " ")
if err != nil { return err }
return os.WriteFile(s.filePath, b, 0o600)
}

func (s *UserStore) CreateUser(email, plaintextPassword string) (*User, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" || plaintextPassword == "" { return nil, fmt.Errorf("email and password required") }
s.mu.Lock(); defer s.mu.Unlock()
if _, exists := s.byEmail[email]; exists { return nil, fmt.Errorf("user already exists") }
hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost)
if err != nil { return nil, err }
id := generateDeterministicID(email)
u := &User{ID: id, Email: email, PasswordHash: string(hash), Roles: []string{"user"}, SubscriptionOK: false, CreatedAt: time.Now().Unix()}
s.byEmail[email] = u
if err := s.save(); err != nil { return nil, err }
return u, nil
}

func (s *UserStore) Authenticate(email, plaintextPassword string) (*User, error) {
email = strings.ToLower(strings.TrimSpace(email))
s.mu.RLock(); u := s.byEmail[email]; s.mu.RUnlock()
if u == nil { return nil, fmt.Errorf("invalid credentials") }
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(plaintextPassword)); err != nil { return nil, fmt.Errorf("invalid credentials") }
return u, nil
}

func (s *UserStore) GetByEmail(email string) *User {
email = strings.ToLower(strings.TrimSpace(email))
s.mu.RLock(); defer s.mu.RUnlock()
return s.byEmail[email]
}

func (s *UserStore) SetSubscription(email string, ok bool) error {
email = strings.ToLower(strings.TrimSpace(email))
s.mu.Lock(); defer s.mu.Unlock()
u := s.byEmail[email]
if u == nil { return fmt.Errorf("user not found") }
u.SubscriptionOK = ok
return s.save()
}

func (s *UserStore) SetStripeCustomerID(email, customerID string) error {
email = strings.ToLower(strings.TrimSpace(email))
s.mu.Lock(); defer s.mu.Unlock()
u := s.byEmail[email]
if u == nil { return fmt.Errorf("user not found") }
u.StripeCustomerID = customerID
return s.save()
}

func (s *UserStore) FindByStripeCustomerID(customerID string) *User {
s.mu.RLock(); defer s.mu.RUnlock()
for _, u := range s.byEmail {
if u.StripeCustomerID == customerID { return u }
}
return nil
}

func generateDeterministicID(seed string) string {
h := sha256.Sum256([]byte(seed))
return base64.RawURLEncoding.EncodeToString(h[:16])
}

// JWT utilities (HMAC-SHA256)

type JWTClaims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Exp int64 `json:"exp"`
}

func getJWTSecret() ([]byte, error) {
secret := strings.TrimSpace(os.Getenv("JWT_SECRET"))
if secret == "" { return nil, fmt.Errorf("JWT_SECRET not set") }
return []byte(secret), nil
}

func createJWT(claims JWTClaims) (string, error) {
head := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
payloadBytes, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
unsigned := head + "." + payload
secret, err := getJWTSecret()
if err != nil { return "", err }
h := hmac.New(sha256.New, secret)
h.Write([]byte(unsigned))
sig := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return unsigned + "." + sig, nil
}

func parseAndValidateJWT(token string) (*JWTClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 { return nil, fmt.Errorf("invalid token format") }
unsigned := parts[0] + "." + parts[1]
secret, err := getJWTSecret()
if err != nil { return nil, err }
h := hmac.New(sha256.New, secret)
h.Write([]byte(unsigned))
expected := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
if !constantTimeEqual(parts[2], expected) { return nil, fmt.Errorf("invalid signature") }
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil { return nil, err }
var claims JWTClaims
if err := json.Unmarshal(payloadBytes, &claims); err != nil { return nil, err }
if time.Now().Unix() > claims.Exp { return nil, fmt.Errorf("token expired") }
return &claims, nil
}

func constantTimeEqual(a, b string) bool {
if len(a) != len(b) { return false }
var v byte
for i := 0; i < len(a); i++ { v |= a[i] ^ b[i] }
return v == 0
}
Loading