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:
2026-03-01 11:41:21 +00:00
parent 5c73295ce5
commit 6e2becb06a
164 changed files with 446560 additions and 0 deletions
+197
View File
@@ -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)
}
}
+275
View File
@@ -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)
}