- Add test/integration.test.ts: getServicePublicKey, full flow (register, login, createCollection, createPointFeature, listFeatures) - Update docs example: registerBySigningServiceKey then loginWithSignature - Document integration tests in typescript-frontend-integration.md Made-with: Cursor
This commit is contained in:
@@ -15,7 +15,7 @@ var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrRegistrationNotConfigured = errors.New("registration by signature not configured; set ADMIN_PUBLIC_KEY")
|
||||
ErrRegistrationNotConfigured = errors.New("registration by signature not configured")
|
||||
ErrInviteInvalid = errors.New("invite invalid")
|
||||
ErrInviteExpired = errors.New("invite expired")
|
||||
ErrInviteExhaust = errors.New("invite exhausted")
|
||||
@@ -30,13 +30,13 @@ type Config struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *store.MemoryStore
|
||||
store store.Store
|
||||
config Config
|
||||
servicePublicKey string
|
||||
}
|
||||
|
||||
func NewService(memoryStore *store.MemoryStore, cfg Config, servicePublicKey string) *Service {
|
||||
return &Service{store: memoryStore, config: cfg, servicePublicKey: servicePublicKey}
|
||||
func NewService(st store.Store, cfg Config, servicePublicKey string) *Service {
|
||||
return &Service{store: st, config: cfg, servicePublicKey: servicePublicKey}
|
||||
}
|
||||
|
||||
type InvitationPayload struct {
|
||||
|
||||
@@ -103,7 +103,12 @@ func statusFromErr(err error) int {
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, err error) {
|
||||
writeJSON(w, statusFromErr(err), map[string]string{"error": err.Error()})
|
||||
payload := map[string]string{"error": err.Error()}
|
||||
if errors.Is(err, app.ErrRegistrationNotConfigured) {
|
||||
payload["code"] = "REGISTRATION_NOT_CONFIGURED"
|
||||
payload["hint"] = "Run ./bin/gen-server-keys.sh to create etc/server-service.env, or set ADMIN_PUBLIC_KEY before starting the API."
|
||||
}
|
||||
writeJSON(w, statusFromErr(err), payload)
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) (string, error) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
type Store interface {
|
||||
UpsertUser(user User)
|
||||
GetUser(publicKey string) (User, error)
|
||||
CreateChallenge(ch Challenge) error
|
||||
GetChallenge(nonce string) (Challenge, error)
|
||||
MarkChallengeUsed(nonce string) error
|
||||
SaveSession(session Session)
|
||||
GetSession(token string) (Session, error)
|
||||
SaveInvitation(inv Invitation) error
|
||||
GetInvitation(jti string) (Invitation, error)
|
||||
IncrementInvitationUsage(jti string) error
|
||||
SaveCollection(c Collection)
|
||||
ListCollectionsByOwner(owner string) []Collection
|
||||
GetCollection(id string) (Collection, error)
|
||||
SaveFeature(f Feature)
|
||||
ListFeaturesByCollection(collectionID string) []Feature
|
||||
GetFeature(featureID string) (Feature, error)
|
||||
DeleteFeature(featureID string) error
|
||||
PruneExpired(now time.Time)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"log"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
//go:embed migrations
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func Migrate(databaseURL string) error {
|
||||
db, err := sql.Open("pgx", databaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
sql, err := migrationsFS.ReadFile("migrations/0001_init.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("migrations applied")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Users
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
inviter TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Challenges (login nonces)
|
||||
CREATE TABLE IF NOT EXISTS challenges (
|
||||
nonce TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Sessions
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
-- Invitations
|
||||
CREATE TABLE IF NOT EXISTS invitations (
|
||||
jti TEXT PRIMARY KEY,
|
||||
inviter_public_key TEXT NOT NULL,
|
||||
invitee_public_key TEXT,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
max_uses INT NOT NULL,
|
||||
used_count INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Collections
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Features (geometry and properties as JSONB)
|
||||
CREATE TABLE IF NOT EXISTS features (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
owner_key TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'Feature',
|
||||
geometry JSONB NOT NULL,
|
||||
properties JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_owner ON collections(owner_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_features_collection ON features(collection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_features_owner ON features(owner_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_challenges_expires ON challenges(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
@@ -0,0 +1,292 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
type PostgresStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPostgresStore(databaseURL string) (*PostgresStore, error) {
|
||||
db, err := sql.Open("pgx", databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PostgresStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpsertUser(user User) {
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT INTO users (public_key, inviter, created_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (public_key) DO UPDATE SET inviter = EXCLUDED.inviter`,
|
||||
user.PublicKey, nullStr(user.Inviter), user.CreatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetUser(publicKey string) (User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(
|
||||
`SELECT public_key, COALESCE(inviter,''), created_at FROM users WHERE public_key = $1`,
|
||||
publicKey,
|
||||
).Scan(&u.PublicKey, &u.Inviter, &u.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateChallenge(ch Challenge) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO challenges (nonce, public_key, expires_at, used) VALUES ($1, $2, $3, $4)`,
|
||||
ch.Nonce, ch.PublicKey, ch.ExpiresAt, ch.Used,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrAlreadyExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetChallenge(nonce string) (Challenge, error) {
|
||||
var ch Challenge
|
||||
err := s.db.QueryRow(
|
||||
`SELECT nonce, public_key, expires_at, used FROM challenges WHERE nonce = $1`,
|
||||
nonce,
|
||||
).Scan(&ch.Nonce, &ch.PublicKey, &ch.ExpiresAt, &ch.Used)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Challenge{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Challenge{}, err
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) MarkChallengeUsed(nonce string) error {
|
||||
res, err := s.db.Exec(`UPDATE challenges SET used = true WHERE nonce = $1`, nonce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) SaveSession(session Session) {
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT INTO sessions (token, public_key, expires_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (token) DO UPDATE SET public_key = EXCLUDED.public_key, expires_at = EXCLUDED.expires_at`,
|
||||
session.Token, session.PublicKey, session.ExpiresAt,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetSession(token string) (Session, error) {
|
||||
var sess Session
|
||||
err := s.db.QueryRow(
|
||||
`SELECT token, public_key, expires_at FROM sessions WHERE token = $1`,
|
||||
token,
|
||||
).Scan(&sess.Token, &sess.PublicKey, &sess.ExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) SaveInvitation(inv Invitation) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO invitations (jti, inviter_public_key, invitee_public_key, expires_at, max_uses, used_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
inv.JTI, inv.InviterPublicKey, nullStr(inv.InviteePublicKey), inv.ExpiresAt, inv.MaxUses, inv.UsedCount,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrAlreadyExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetInvitation(jti string) (Invitation, error) {
|
||||
var inv Invitation
|
||||
var invitee sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT jti, inviter_public_key, invitee_public_key, expires_at, max_uses, used_count
|
||||
FROM invitations WHERE jti = $1`,
|
||||
jti,
|
||||
).Scan(&inv.JTI, &inv.InviterPublicKey, &invitee, &inv.ExpiresAt, &inv.MaxUses, &inv.UsedCount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Invitation{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
inv.InviteePublicKey = invitee.String
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) IncrementInvitationUsage(jti string) error {
|
||||
res, err := s.db.Exec(`UPDATE invitations SET used_count = used_count + 1 WHERE jti = $1`, jti)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) SaveCollection(c Collection) {
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT INTO collections (id, owner_key, name, created_at) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`,
|
||||
c.ID, c.OwnerKey, c.Name, c.CreatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListCollectionsByOwner(owner string) []Collection {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, owner_key, name, created_at FROM collections WHERE owner_key = $1 ORDER BY created_at`,
|
||||
owner,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []Collection
|
||||
for rows.Next() {
|
||||
var c Collection
|
||||
if err := rows.Scan(&c.ID, &c.OwnerKey, &c.Name, &c.CreatedAt); err != nil {
|
||||
return result
|
||||
}
|
||||
result = append(result, c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetCollection(id string) (Collection, error) {
|
||||
var c Collection
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, owner_key, name, created_at FROM collections WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&c.ID, &c.OwnerKey, &c.Name, &c.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Collection{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Collection{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) SaveFeature(f Feature) {
|
||||
geom, _ := json.Marshal(f.Geometry)
|
||||
props, _ := json.Marshal(f.Properties)
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT INTO features (id, collection_id, owner_key, type, geometry, properties, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET geometry = EXCLUDED.geometry, properties = EXCLUDED.properties, updated_at = EXCLUDED.updated_at`,
|
||||
f.ID, f.CollectionID, f.OwnerKey, f.Type, geom, props, f.CreatedAt, f.UpdatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListFeaturesByCollection(collectionID string) []Feature {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, collection_id, owner_key, type, geometry, properties, created_at, updated_at
|
||||
FROM features WHERE collection_id = $1 ORDER BY created_at`,
|
||||
collectionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []Feature
|
||||
for rows.Next() {
|
||||
var f Feature
|
||||
var geom, props []byte
|
||||
if err := rows.Scan(&f.ID, &f.CollectionID, &f.OwnerKey, &f.Type, &geom, &props, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return result
|
||||
}
|
||||
_ = json.Unmarshal(geom, &f.Geometry)
|
||||
_ = json.Unmarshal(props, &f.Properties)
|
||||
result = append(result, f)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetFeature(featureID string) (Feature, error) {
|
||||
var f Feature
|
||||
var geom, props []byte
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, collection_id, owner_key, type, geometry, properties, created_at, updated_at
|
||||
FROM features WHERE id = $1`,
|
||||
featureID,
|
||||
).Scan(&f.ID, &f.CollectionID, &f.OwnerKey, &f.Type, &geom, &props, &f.CreatedAt, &f.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Feature{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Feature{}, err
|
||||
}
|
||||
_ = json.Unmarshal(geom, &f.Geometry)
|
||||
_ = json.Unmarshal(props, &f.Properties)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteFeature(featureID string) error {
|
||||
res, err := s.db.Exec(`DELETE FROM features WHERE id = $1`, featureID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) PruneExpired(now time.Time) {
|
||||
_, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now)
|
||||
_, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now)
|
||||
_, _ = s.db.Exec(`DELETE FROM invitations WHERE expires_at < $1`, now)
|
||||
}
|
||||
|
||||
func nullStr(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "duplicate key") || strings.Contains(s, "23505")
|
||||
}
|
||||
Reference in New Issue
Block a user