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)
|
||||
}
|
||||
Reference in New Issue
Block a user