Implement geo backend, TS client, frontend, and CI tests.
Add a Go HTTP API with Ed25519 auth and invitation onboarding, user-scoped GeoJSON Point management, a Bun-tested @noble/ed25519 TypeScript client, static Vue/Vuetify frontend integration, and a Gitea CI workflow running both Go and Bun test suites. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"momswap/backend/internal/auth"
|
||||
"momswap/backend/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrInviteInvalid = errors.New("invite invalid")
|
||||
ErrInviteExpired = errors.New("invite expired")
|
||||
ErrInviteExhaust = errors.New("invite exhausted")
|
||||
ErrAlreadyUser = errors.New("user already registered")
|
||||
ErrCollectionMiss = errors.New("collection missing")
|
||||
ErrFeatureMiss = errors.New("feature missing")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ChallengeTTL time.Duration
|
||||
SessionTTL time.Duration
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *store.MemoryStore
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewService(memoryStore *store.MemoryStore, cfg Config) *Service {
|
||||
return &Service{store: memoryStore, config: cfg}
|
||||
}
|
||||
|
||||
type InvitationPayload struct {
|
||||
JTI string `json:"jti"`
|
||||
InviterPublicKey string `json:"inviterPublicKey"`
|
||||
InviteePublicKey string `json:"inviteePublicKey,omitempty"`
|
||||
ExpiresAtUnix int64 `json:"expiresAtUnix"`
|
||||
MaxUses int `json:"maxUses"`
|
||||
}
|
||||
|
||||
func (s *Service) BootstrapAdmin(publicKey string) {
|
||||
if publicKey == "" {
|
||||
return
|
||||
}
|
||||
s.store.UpsertUser(store.User{
|
||||
PublicKey: publicKey,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) CreateChallenge(publicKey string) (string, error) {
|
||||
if publicKey == "" {
|
||||
return "", fmt.Errorf("%w: missing public key", ErrBadRequest)
|
||||
}
|
||||
|
||||
nonce, err := auth.NewRandomToken(24)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = s.store.CreateChallenge(store.Challenge{
|
||||
Nonce: nonce,
|
||||
PublicKey: publicKey,
|
||||
ExpiresAt: time.Now().UTC().Add(s.config.ChallengeTTL),
|
||||
Used: false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return nonce, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(publicKey, nonce, signature string) (string, error) {
|
||||
ch, err := s.store.GetChallenge(nonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: challenge not found", ErrUnauthorized)
|
||||
}
|
||||
if ch.Used || time.Now().UTC().After(ch.ExpiresAt) {
|
||||
return "", fmt.Errorf("%w: challenge expired", ErrUnauthorized)
|
||||
}
|
||||
if ch.PublicKey != publicKey {
|
||||
return "", fmt.Errorf("%w: challenge key mismatch", ErrUnauthorized)
|
||||
}
|
||||
if _, err := s.store.GetUser(publicKey); err != nil {
|
||||
return "", fmt.Errorf("%w: user not registered", ErrUnauthorized)
|
||||
}
|
||||
|
||||
msg := "login:" + nonce
|
||||
if err := auth.VerifySignature(publicKey, msg, signature); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrUnauthorized, err)
|
||||
}
|
||||
_ = s.store.MarkChallengeUsed(nonce)
|
||||
|
||||
token, err := auth.NewRandomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.store.SaveSession(store.Session{
|
||||
Token: token,
|
||||
PublicKey: publicKey,
|
||||
ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL),
|
||||
})
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *Service) AuthenticateSession(token string) (string, error) {
|
||||
session, err := s.store.GetSession(token)
|
||||
if err != nil || time.Now().UTC().After(session.ExpiresAt) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
return session.PublicKey, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateInvitation(authenticatedKey, invitePayloadB64, inviteSigB64 string) error {
|
||||
payloadRaw, err := base64.RawURLEncoding.DecodeString(invitePayloadB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: decode payload", ErrBadRequest)
|
||||
}
|
||||
|
||||
var payload InvitationPayload
|
||||
if err := json.Unmarshal(payloadRaw, &payload); err != nil {
|
||||
return fmt.Errorf("%w: invalid payload json", ErrBadRequest)
|
||||
}
|
||||
if payload.InviterPublicKey != authenticatedKey {
|
||||
return fmt.Errorf("%w: inviter mismatch", ErrForbidden)
|
||||
}
|
||||
if payload.JTI == "" || payload.MaxUses <= 0 || payload.ExpiresAtUnix <= 0 {
|
||||
return fmt.Errorf("%w: invalid invite fields", ErrBadRequest)
|
||||
}
|
||||
if time.Now().UTC().After(time.Unix(payload.ExpiresAtUnix, 0).UTC()) {
|
||||
return ErrInviteExpired
|
||||
}
|
||||
if _, err := s.store.GetUser(payload.InviterPublicKey); err != nil {
|
||||
return fmt.Errorf("%w: inviter user missing", ErrForbidden)
|
||||
}
|
||||
|
||||
if err := auth.VerifySignature(payload.InviterPublicKey, "invite:"+invitePayloadB64, inviteSigB64); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrBadRequest, err)
|
||||
}
|
||||
|
||||
return s.store.SaveInvitation(store.Invitation{
|
||||
JTI: payload.JTI,
|
||||
InviterPublicKey: payload.InviterPublicKey,
|
||||
InviteePublicKey: payload.InviteePublicKey,
|
||||
ExpiresAt: time.Unix(payload.ExpiresAtUnix, 0).UTC(),
|
||||
MaxUses: payload.MaxUses,
|
||||
UsedCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Register(newPublicKey, invitePayloadB64, inviteSigB64, proofSigB64 string) error {
|
||||
if newPublicKey == "" {
|
||||
return fmt.Errorf("%w: missing public key", ErrBadRequest)
|
||||
}
|
||||
if _, err := s.store.GetUser(newPublicKey); err == nil {
|
||||
return ErrAlreadyUser
|
||||
}
|
||||
|
||||
payloadRaw, err := base64.RawURLEncoding.DecodeString(invitePayloadB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: decode payload", ErrBadRequest)
|
||||
}
|
||||
var payload InvitationPayload
|
||||
if err := json.Unmarshal(payloadRaw, &payload); err != nil {
|
||||
return fmt.Errorf("%w: invalid payload", ErrBadRequest)
|
||||
}
|
||||
|
||||
if err := auth.VerifySignature(payload.InviterPublicKey, "invite:"+invitePayloadB64, inviteSigB64); err != nil {
|
||||
return fmt.Errorf("%w: invite signature", ErrInviteInvalid)
|
||||
}
|
||||
if _, err := s.store.GetUser(payload.InviterPublicKey); err != nil {
|
||||
return fmt.Errorf("%w: inviter missing", ErrInviteInvalid)
|
||||
}
|
||||
inv, err := s.store.GetInvitation(payload.JTI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: invite unknown", ErrInviteInvalid)
|
||||
}
|
||||
if inv.InviterPublicKey != payload.InviterPublicKey {
|
||||
return fmt.Errorf("%w: inviter mismatch", ErrInviteInvalid)
|
||||
}
|
||||
if time.Now().UTC().After(inv.ExpiresAt) {
|
||||
return ErrInviteExpired
|
||||
}
|
||||
if inv.UsedCount >= inv.MaxUses {
|
||||
return ErrInviteExhaust
|
||||
}
|
||||
if inv.InviteePublicKey != "" && inv.InviteePublicKey != newPublicKey {
|
||||
return fmt.Errorf("%w: invite bound to another key", ErrInviteInvalid)
|
||||
}
|
||||
|
||||
proofMessage := "register:" + newPublicKey + ":" + payload.JTI
|
||||
if err := auth.VerifySignature(newPublicKey, proofMessage, proofSigB64); err != nil {
|
||||
return fmt.Errorf("%w: register proof", ErrInviteInvalid)
|
||||
}
|
||||
|
||||
if err := s.store.IncrementInvitationUsage(payload.JTI); err != nil {
|
||||
return err
|
||||
}
|
||||
s.store.UpsertUser(store.User{
|
||||
PublicKey: newPublicKey,
|
||||
Inviter: payload.InviterPublicKey,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePoint(point store.Point) error {
|
||||
if point.Type != "Point" {
|
||||
return fmt.Errorf("%w: geometry type must be Point", ErrBadRequest)
|
||||
}
|
||||
if len(point.Coordinates) != 2 {
|
||||
return fmt.Errorf("%w: coordinates must have lon/lat", ErrBadRequest)
|
||||
}
|
||||
lon, lat := point.Coordinates[0], point.Coordinates[1]
|
||||
if lon < -180 || lon > 180 || lat < -90 || lat > 90 {
|
||||
return fmt.Errorf("%w: invalid lon/lat bounds", ErrBadRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateCollection(ownerKey, name string) (store.Collection, error) {
|
||||
if name == "" {
|
||||
return store.Collection{}, fmt.Errorf("%w: collection name required", ErrBadRequest)
|
||||
}
|
||||
id, err := auth.NewRandomToken(12)
|
||||
if err != nil {
|
||||
return store.Collection{}, err
|
||||
}
|
||||
c := store.Collection{
|
||||
ID: id,
|
||||
OwnerKey: ownerKey,
|
||||
Name: name,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
s.store.SaveCollection(c)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListCollections(ownerKey string) []store.Collection {
|
||||
return s.store.ListCollectionsByOwner(ownerKey)
|
||||
}
|
||||
|
||||
func (s *Service) CreateFeature(ownerKey, collectionID string, geometry store.Point, properties map[string]interface{}) (store.Feature, error) {
|
||||
collection, err := s.store.GetCollection(collectionID)
|
||||
if err != nil {
|
||||
return store.Feature{}, ErrCollectionMiss
|
||||
}
|
||||
if collection.OwnerKey != ownerKey {
|
||||
return store.Feature{}, ErrForbidden
|
||||
}
|
||||
if err := validatePoint(geometry); err != nil {
|
||||
return store.Feature{}, err
|
||||
}
|
||||
|
||||
id, err := auth.NewRandomToken(12)
|
||||
if err != nil {
|
||||
return store.Feature{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
feature := store.Feature{
|
||||
ID: id,
|
||||
CollectionID: collectionID,
|
||||
OwnerKey: ownerKey,
|
||||
Type: "Feature",
|
||||
Geometry: geometry,
|
||||
Properties: properties,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.store.SaveFeature(feature)
|
||||
return feature, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListFeatures(ownerKey, collectionID string) ([]store.Feature, error) {
|
||||
collection, err := s.store.GetCollection(collectionID)
|
||||
if err != nil {
|
||||
return nil, ErrCollectionMiss
|
||||
}
|
||||
if collection.OwnerKey != ownerKey {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
return s.store.ListFeaturesByCollection(collectionID), nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteFeature(ownerKey, featureID string) error {
|
||||
feature, err := s.store.GetFeature(featureID)
|
||||
if err != nil {
|
||||
return ErrFeatureMiss
|
||||
}
|
||||
if feature.OwnerKey != ownerKey {
|
||||
return ErrForbidden
|
||||
}
|
||||
return s.store.DeleteFeature(featureID)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func NewRandomToken(byteLen int) (string, error) {
|
||||
if byteLen <= 0 {
|
||||
return "", errors.New("byteLen must be greater than zero")
|
||||
}
|
||||
|
||||
b := make([]byte, byteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("read random bytes: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func VerifySignature(publicKeyBase64, message, signatureBase64 string) error {
|
||||
pubBytes, err := base64.RawURLEncoding.DecodeString(publicKeyBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode public key: %w", err)
|
||||
}
|
||||
if len(pubBytes) != ed25519.PublicKeySize {
|
||||
return errors.New("invalid public key length")
|
||||
}
|
||||
|
||||
sigBytes, err := base64.RawURLEncoding.DecodeString(signatureBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
if len(sigBytes) != ed25519.SignatureSize {
|
||||
return errors.New("invalid signature length")
|
||||
}
|
||||
|
||||
if !ed25519.Verify(ed25519.PublicKey(pubBytes), []byte(message), sigBytes) {
|
||||
return errors.New("signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"momswap/backend/internal/app"
|
||||
httpapi "momswap/backend/internal/http"
|
||||
"momswap/backend/internal/store"
|
||||
)
|
||||
|
||||
func newTestServer(adminPublicKey string) *httptest.Server {
|
||||
memory := store.NewMemoryStore()
|
||||
svc := app.NewService(memory, app.Config{
|
||||
ChallengeTTL: 5 * time.Minute,
|
||||
SessionTTL: 24 * time.Hour,
|
||||
})
|
||||
svc.BootstrapAdmin(adminPublicKey)
|
||||
api := httpapi.NewAPI(svc)
|
||||
return httptest.NewServer(api.Routes())
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value interface{}) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, client *http.Client, url string, body interface{}, token string) (*http.Response, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(mustJSON(t, body)))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out := map[string]interface{}{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func loginUser(t *testing.T, client *http.Client, baseURL, pubB64 string, priv ed25519.PrivateKey) string {
|
||||
t.Helper()
|
||||
chResp, chData := postJSON(t, client, baseURL+"/v1/auth/challenge", map[string]string{"publicKey": pubB64}, "")
|
||||
if chResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("create challenge status=%d body=%v", chResp.StatusCode, chData)
|
||||
}
|
||||
nonce := chData["nonce"].(string)
|
||||
sig := ed25519.Sign(priv, []byte("login:"+nonce))
|
||||
loginResp, loginData := postJSON(t, client, baseURL+"/v1/auth/login", map[string]string{
|
||||
"publicKey": pubB64,
|
||||
"nonce": nonce,
|
||||
"signature": base64.RawURLEncoding.EncodeToString(sig),
|
||||
}, "")
|
||||
if loginResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%v", loginResp.StatusCode, loginData)
|
||||
}
|
||||
return loginData["accessToken"].(string)
|
||||
}
|
||||
|
||||
func registerUserViaAdmin(t *testing.T, client *http.Client, baseURL, adminPub string, adminPriv ed25519.PrivateKey, adminToken string, userPub string, userPriv ed25519.PrivateKey, jti string) {
|
||||
t.Helper()
|
||||
payload := app.InvitationPayload{
|
||||
JTI: jti,
|
||||
InviterPublicKey: adminPub,
|
||||
ExpiresAtUnix: time.Now().Add(time.Hour).Unix(),
|
||||
MaxUses: 1,
|
||||
}
|
||||
payloadRaw := mustJSON(t, payload)
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadRaw)
|
||||
inviteSig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(adminPriv, []byte("invite:"+payloadB64)))
|
||||
|
||||
inviteResp, inviteData := postJSON(t, client, baseURL+"/v1/invitations", map[string]string{
|
||||
"invitePayloadB64": payloadB64,
|
||||
"inviteSignature": inviteSig,
|
||||
}, adminToken)
|
||||
if inviteResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create invitation status=%d body=%v", inviteResp.StatusCode, inviteData)
|
||||
}
|
||||
|
||||
proofSig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(userPriv, []byte("register:"+userPub+":"+jti)))
|
||||
registerResp, registerData := postJSON(t, client, baseURL+"/v1/auth/register", map[string]string{
|
||||
"publicKey": userPub,
|
||||
"invitePayloadB64": payloadB64,
|
||||
"inviteSignature": inviteSig,
|
||||
"proofSignature": proofSig,
|
||||
}, "")
|
||||
if registerResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("register status=%d body=%v", registerResp.StatusCode, registerData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLoginAndProfile(t *testing.T) {
|
||||
adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin key: %v", err)
|
||||
}
|
||||
adminPubB64 := base64.RawURLEncoding.EncodeToString(adminPub)
|
||||
|
||||
server := newTestServer(adminPubB64)
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
|
||||
adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv)
|
||||
|
||||
userPub, userPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user key: %v", err)
|
||||
}
|
||||
userPubB64 := base64.RawURLEncoding.EncodeToString(userPub)
|
||||
registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, userPubB64, userPriv, "invite-1")
|
||||
|
||||
userToken := loginUser(t, client, server.URL, userPubB64, userPriv)
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/me/keys", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("me request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("me status=%d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionOwnershipIsolation(t *testing.T) {
|
||||
adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin key: %v", err)
|
||||
}
|
||||
adminPubB64 := base64.RawURLEncoding.EncodeToString(adminPub)
|
||||
|
||||
server := newTestServer(adminPubB64)
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
|
||||
adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv)
|
||||
|
||||
user1Pub, user1Priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
user1PubB64 := base64.RawURLEncoding.EncodeToString(user1Pub)
|
||||
registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user1PubB64, user1Priv, "invite-u1")
|
||||
user1Token := loginUser(t, client, server.URL, user1PubB64, user1Priv)
|
||||
|
||||
user2Pub, user2Priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
user2PubB64 := base64.RawURLEncoding.EncodeToString(user2Pub)
|
||||
registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user2PubB64, user2Priv, "invite-u2")
|
||||
user2Token := loginUser(t, client, server.URL, user2PubB64, user2Priv)
|
||||
|
||||
createCollectionResp, createCollectionData := postJSON(t, client, server.URL+"/v1/collections", map[string]string{
|
||||
"name": "my places",
|
||||
}, user1Token)
|
||||
if createCollectionResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create collection status=%d body=%v", createCollectionResp.StatusCode, createCollectionData)
|
||||
}
|
||||
collectionID := createCollectionData["id"].(string)
|
||||
|
||||
createFeatureResp, createFeatureData := postJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", map[string]interface{}{
|
||||
"geometry": map[string]interface{}{
|
||||
"type": "Point",
|
||||
"coordinates": []float64{-16.6291, 28.4636},
|
||||
},
|
||||
"properties": map[string]interface{}{
|
||||
"name": "Santa Cruz",
|
||||
},
|
||||
}, user1Token)
|
||||
if createFeatureResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create feature status=%d body=%v", createFeatureResp.StatusCode, createFeatureData)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/collections/"+collectionID+"/features", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user2Token)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list features as user2: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"momswap/backend/internal/app"
|
||||
"momswap/backend/internal/store"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
service *app.Service
|
||||
}
|
||||
|
||||
func NewAPI(svc *app.Service) *API {
|
||||
return &API{service: svc}
|
||||
}
|
||||
|
||||
func (a *API) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", a.health)
|
||||
mux.HandleFunc("POST /v1/auth/challenge", a.createChallenge)
|
||||
mux.HandleFunc("POST /v1/auth/login", a.login)
|
||||
mux.HandleFunc("POST /v1/auth/register", a.register)
|
||||
mux.HandleFunc("POST /v1/invitations", a.createInvitation)
|
||||
mux.HandleFunc("GET /v1/me/keys", a.me)
|
||||
|
||||
mux.HandleFunc("POST /v1/collections", a.createCollection)
|
||||
mux.HandleFunc("GET /v1/collections", a.listCollections)
|
||||
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
|
||||
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
|
||||
mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature)
|
||||
|
||||
return withCORS(mux)
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, target interface{}) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(target)
|
||||
}
|
||||
|
||||
func statusFromErr(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, app.ErrUnauthorized):
|
||||
return http.StatusUnauthorized
|
||||
case errors.Is(err, app.ErrForbidden):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, app.ErrBadRequest):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, app.ErrInviteInvalid),
|
||||
errors.Is(err, app.ErrInviteExpired),
|
||||
errors.Is(err, app.ErrInviteExhaust):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, app.ErrCollectionMiss), errors.Is(err, app.ErrFeatureMiss),
|
||||
errors.Is(err, store.ErrNotFound):
|
||||
return http.StatusNotFound
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, err error) {
|
||||
writeJSON(w, statusFromErr(err), map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) (string, error) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return "", app.ErrUnauthorized
|
||||
}
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
|
||||
return "", app.ErrUnauthorized
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
func (a *API) authUser(r *http.Request) (string, error) {
|
||||
token, err := bearerToken(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.service.AuthenticateSession(token)
|
||||
}
|
||||
|
||||
func (a *API) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "time": time.Now().UTC().Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
func (a *API) createChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
nonce, err := a.service.CreateChallenge(req.PublicKey)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"nonce": nonce, "messageToSign": "login:" + nonce})
|
||||
}
|
||||
|
||||
func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
Nonce string `json:"nonce"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
token, err := a.service.Login(req.PublicKey, req.Nonce, req.Signature)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"accessToken": token})
|
||||
}
|
||||
|
||||
func (a *API) register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
InvitePayloadB64 string `json:"invitePayloadB64"`
|
||||
InviteSignature string `json:"inviteSignature"`
|
||||
ProofSignature string `json:"proofSignature"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
if err := a.service.Register(req.PublicKey, req.InvitePayloadB64, req.InviteSignature, req.ProofSignature); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"})
|
||||
}
|
||||
|
||||
func (a *API) createInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
InvitePayloadB64 string `json:"invitePayloadB64"`
|
||||
InviteSignature string `json:"inviteSignature"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
if err := a.service.CreateInvitation(user, req.InvitePayloadB64, req.InviteSignature); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "invitation stored"})
|
||||
}
|
||||
|
||||
func (a *API) me(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"publicKey": user})
|
||||
}
|
||||
|
||||
func (a *API) createCollection(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
collection, err := a.service.CreateCollection(user, req.Name)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, collection)
|
||||
}
|
||||
|
||||
func (a *API) listCollections(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"collections": a.service.ListCollections(user)})
|
||||
}
|
||||
|
||||
func (a *API) createFeature(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
collectionID := r.PathValue("id")
|
||||
var req struct {
|
||||
Geometry store.Point `json:"geometry"`
|
||||
Properties map[string]interface{} `json:"properties"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeErr(w, app.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
feature, err := a.service.CreateFeature(user, collectionID, req.Geometry, req.Properties)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, feature)
|
||||
}
|
||||
|
||||
func (a *API) listFeatures(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
collectionID := r.PathValue("id")
|
||||
features, err := a.service.ListFeatures(user, collectionID)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"features": features})
|
||||
}
|
||||
|
||||
func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
featureID := r.PathValue("id")
|
||||
if err := a.service.DeleteFeature(user, featureID); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAlreadyExists = errors.New("already exists")
|
||||
)
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
users map[string]User
|
||||
challenges map[string]Challenge
|
||||
sessions map[string]Session
|
||||
invitations map[string]Invitation
|
||||
collections map[string]Collection
|
||||
features map[string]Feature
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
users: make(map[string]User),
|
||||
challenges: make(map[string]Challenge),
|
||||
sessions: make(map[string]Session),
|
||||
invitations: make(map[string]Invitation),
|
||||
collections: make(map[string]Collection),
|
||||
features: make(map[string]Feature),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) UpsertUser(user User) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.users[user.PublicKey] = user
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetUser(publicKey string) (User, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
user, ok := s.users[publicKey]
|
||||
if !ok {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateChallenge(ch Challenge) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.challenges[ch.Nonce]; exists {
|
||||
return ErrAlreadyExists
|
||||
}
|
||||
s.challenges[ch.Nonce] = ch
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetChallenge(nonce string) (Challenge, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
ch, ok := s.challenges[nonce]
|
||||
if !ok {
|
||||
return Challenge{}, ErrNotFound
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) MarkChallengeUsed(nonce string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ch, ok := s.challenges[nonce]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
ch.Used = true
|
||||
s.challenges[nonce] = ch
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SaveSession(session Session) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[session.Token] = session
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetSession(token string) (Session, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, ok := s.sessions[token]
|
||||
if !ok {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SaveInvitation(inv Invitation) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.invitations[inv.JTI]; exists {
|
||||
return ErrAlreadyExists
|
||||
}
|
||||
s.invitations[inv.JTI] = inv
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetInvitation(jti string) (Invitation, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
inv, ok := s.invitations[jti]
|
||||
if !ok {
|
||||
return Invitation{}, ErrNotFound
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) IncrementInvitationUsage(jti string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
inv, ok := s.invitations[jti]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
inv.UsedCount++
|
||||
s.invitations[jti] = inv
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SaveCollection(c Collection) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.collections[c.ID] = c
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListCollectionsByOwner(owner string) []Collection {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]Collection, 0)
|
||||
for _, c := range s.collections {
|
||||
if c.OwnerKey == owner {
|
||||
result = append(result, c)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetCollection(id string) (Collection, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
c, ok := s.collections[id]
|
||||
if !ok {
|
||||
return Collection{}, ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SaveFeature(f Feature) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.features[f.ID] = f
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListFeaturesByCollection(collectionID string) []Feature {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]Feature, 0)
|
||||
for _, f := range s.features {
|
||||
if f.CollectionID == collectionID {
|
||||
result = append(result, f)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetFeature(featureID string) (Feature, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
f, ok := s.features[featureID]
|
||||
if !ok {
|
||||
return Feature{}, ErrNotFound
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) DeleteFeature(featureID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.features[featureID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.features, featureID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) PruneExpired(now time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for nonce, ch := range s.challenges {
|
||||
if ch.ExpiresAt.Before(now) {
|
||||
delete(s.challenges, nonce)
|
||||
}
|
||||
}
|
||||
for token, sess := range s.sessions {
|
||||
if sess.ExpiresAt.Before(now) {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
}
|
||||
for jti, inv := range s.invitations {
|
||||
if inv.ExpiresAt.Before(now) {
|
||||
delete(s.invitations, jti)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
Inviter string `json:"inviter,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Challenge struct {
|
||||
Nonce string
|
||||
PublicKey string
|
||||
ExpiresAt time.Time
|
||||
Used bool
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string
|
||||
PublicKey string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
JTI string
|
||||
InviterPublicKey string
|
||||
InviteePublicKey string
|
||||
ExpiresAt time.Time
|
||||
MaxUses int
|
||||
UsedCount int
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
ID string `json:"id"`
|
||||
OwnerKey string `json:"ownerKey"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates []float64 `json:"coordinates"`
|
||||
}
|
||||
|
||||
type Feature struct {
|
||||
ID string `json:"id"`
|
||||
CollectionID string `json:"collectionId"`
|
||||
OwnerKey string `json:"ownerKey"`
|
||||
Type string `json:"type"`
|
||||
Geometry Point `json:"geometry"`
|
||||
Properties map[string]interface{} `json:"properties"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
Reference in New Issue
Block a user