- 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:
@@ -7,7 +7,7 @@ This file gives future coding agents a fast path map for this repository.
|
||||
- API entrypoint: `cmd/api/main.go`
|
||||
- HTTP routes/handlers: `internal/http/handlers.go`
|
||||
- Core domain logic: `internal/app/service.go`
|
||||
- In-memory persistence: `internal/store/`
|
||||
- Persistence: `internal/store/` — PostgreSQL (when `DATABASE_URL` set) or in-memory; migrations in `internal/store/migrations/`
|
||||
- Auth utilities: `internal/auth/`
|
||||
- Frontend static app: `web/`
|
||||
- TypeScript API client: `libs/geo-api-client/`
|
||||
|
||||
@@ -52,7 +52,7 @@ Or use `./bin/up.sh` which runs the key generation if needed.
|
||||
This starts:
|
||||
|
||||
- `db` (`postgis/postgis`) on `5432`
|
||||
- `api` on `8122`, wired with `DATABASE_URL` to the `db` service
|
||||
- `api` on `8122` — uses PostgreSQL via `DATABASE_URL` (migrations run on startup)
|
||||
|
||||
Stop the service:
|
||||
|
||||
|
||||
+16
-2
@@ -27,8 +27,22 @@ func main() {
|
||||
servicePublicKey = adminPublicKey
|
||||
}
|
||||
|
||||
memory := store.NewMemoryStore()
|
||||
service := app.NewService(memory, app.Config{
|
||||
var st store.Store
|
||||
if databaseURL := os.Getenv("DATABASE_URL"); databaseURL != "" {
|
||||
if err := store.Migrate(databaseURL); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pg, err := store.NewPostgresStore(databaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("postgres: %v", err)
|
||||
}
|
||||
defer pg.Close()
|
||||
st = pg
|
||||
} else {
|
||||
st = store.NewMemoryStore()
|
||||
}
|
||||
|
||||
service := app.NewService(st, app.Config{
|
||||
ChallengeTTL: 5 * time.Minute,
|
||||
SessionTTL: 24 * time.Hour,
|
||||
}, servicePublicKey)
|
||||
|
||||
@@ -25,10 +25,12 @@ Deployment: API is proxied via reverse proxy from `https://momswap.produktor.duc
|
||||
```bash
|
||||
cd libs/geo-api-client
|
||||
bun install
|
||||
bun test
|
||||
bun test # unit + integration tests (docs flow)
|
||||
bun run build
|
||||
```
|
||||
|
||||
Integration tests in `test/integration.test.ts` cover the recommended flow: register, login, create collection, create point feature, list features.
|
||||
|
||||
## Public API (current)
|
||||
|
||||
### Class: `GeoApiClient`
|
||||
@@ -80,7 +82,6 @@ Server keys are generated with `./bin/gen-server-keys.sh` and stored in `etc/`.
|
||||
import { GeoApiClient } from "../libs/geo-api-client/dist/index.js";
|
||||
|
||||
const storage = window.localStorage;
|
||||
|
||||
const storageLike = {
|
||||
getItem: (key: string) => storage.getItem(key),
|
||||
setItem: (key: string, value: string) => storage.setItem(key, value),
|
||||
@@ -88,8 +89,14 @@ const storageLike = {
|
||||
};
|
||||
|
||||
const client = new GeoApiClient("https://momswap.produktor.duckdns.org", storageLike);
|
||||
|
||||
const keys = await client.ensureKeysInStorage();
|
||||
|
||||
// Register (ignored if already registered); then login
|
||||
try {
|
||||
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
|
||||
} catch (_) {
|
||||
// Already registered or registration disabled
|
||||
}
|
||||
await client.loginWithSignature(keys.publicKey, keys.privateKey);
|
||||
|
||||
const created = await client.createCollection("My Places");
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
module momswap/backend
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -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")
|
||||
}
|
||||
@@ -52,8 +52,9 @@ export class GeoApiClient {
|
||||
const body = init.body === undefined ? undefined : JSON.stringify(init.body);
|
||||
const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, body });
|
||||
if (!res.ok) {
|
||||
const maybeJson = await res.json().catch(() => ({}));
|
||||
const msg = (maybeJson as { error?: string }).error ?? `HTTP ${res.status}`;
|
||||
const maybeJson = (await res.json().catch(() => ({}))) as { error?: string; hint?: string; code?: string };
|
||||
let msg = maybeJson.error ?? `HTTP ${res.status}`;
|
||||
if (maybeJson.hint) msg += `. ${maybeJson.hint}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (res.status === 204) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Integration tests for GeoApiClient against a mock API server.
|
||||
* Covers the full flow from docs: register, login, collections, features.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { verifyAsync } from "@noble/ed25519";
|
||||
import { base64UrlToBytes, bytesToBase64Url, textToBytes } from "../src/encoding";
|
||||
import { GeoApiClient } from "../src/GeoApiClient";
|
||||
import { generateKeyPair, signMessage } from "../src/keys";
|
||||
|
||||
class MemoryStorage {
|
||||
private data = new Map<string, string>();
|
||||
getItem(key: string): string | null {
|
||||
return this.data.get(key) ?? null;
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.data.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function createMockServer(): Promise<{ url: string; server: ReturnType<typeof Bun.serve> }> {
|
||||
const serverKeypair = await generateKeyPair();
|
||||
const users = new Set<string>();
|
||||
const sessions = new Map<string, string>();
|
||||
let collectionId = 0;
|
||||
let featureId = 0;
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
const method = req.method;
|
||||
|
||||
// GET /v1/service-key
|
||||
if (method === "GET" && path === "/v1/service-key") {
|
||||
return Response.json({ publicKey: serverKeypair.publicKey });
|
||||
}
|
||||
|
||||
// POST /v1/auth/challenge
|
||||
if (method === "POST" && path === "/v1/auth/challenge") {
|
||||
const body = (await req.json()) as { publicKey?: string };
|
||||
if (!body?.publicKey) {
|
||||
return Response.json({ error: "missing publicKey" }, { status: 400 });
|
||||
}
|
||||
const nonce = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(24)));
|
||||
return Response.json({ nonce, messageToSign: `login:${nonce}` });
|
||||
}
|
||||
|
||||
// POST /v1/auth/register-by-signature
|
||||
if (method === "POST" && path === "/v1/auth/register-by-signature") {
|
||||
const body = (await req.json()) as { publicKey?: string; signature?: string };
|
||||
if (!body?.publicKey || !body?.signature) {
|
||||
return Response.json({ error: "missing fields" }, { status: 400 });
|
||||
}
|
||||
const ok = await verifyAsync(
|
||||
base64UrlToBytes(body.signature),
|
||||
textToBytes(serverKeypair.publicKey),
|
||||
base64UrlToBytes(body.publicKey)
|
||||
);
|
||||
if (!ok) {
|
||||
return Response.json({ error: "invalid signature" }, { status: 400 });
|
||||
}
|
||||
users.add(body.publicKey);
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
|
||||
// POST /v1/auth/login
|
||||
if (method === "POST" && path === "/v1/auth/login") {
|
||||
const body = (await req.json()) as { publicKey?: string; nonce?: string; signature?: string };
|
||||
if (!body?.publicKey || !body?.nonce || !body?.signature) {
|
||||
return Response.json({ error: "missing fields" }, { status: 400 });
|
||||
}
|
||||
const msg = `login:${body.nonce}`;
|
||||
const ok = await verifyAsync(
|
||||
base64UrlToBytes(body.signature),
|
||||
textToBytes(msg),
|
||||
base64UrlToBytes(body.publicKey)
|
||||
);
|
||||
if (!ok) {
|
||||
return Response.json({ error: "invalid signature" }, { status: 401 });
|
||||
}
|
||||
if (!users.has(body.publicKey)) {
|
||||
return Response.json({ error: "user not registered" }, { status: 401 });
|
||||
}
|
||||
const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
||||
sessions.set(token, body.publicKey);
|
||||
return Response.json({ accessToken: token });
|
||||
}
|
||||
|
||||
// Bearer auth helper
|
||||
const auth = req.headers.get("Authorization");
|
||||
const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
const user = token ? sessions.get(token) : null;
|
||||
if (!user && path.startsWith("/v1/collections") && method !== "GET") {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// POST /v1/collections
|
||||
if (method === "POST" && path === "/v1/collections") {
|
||||
const body = (await req.json()) as { name?: string };
|
||||
collectionId++;
|
||||
const c = { id: `col-${collectionId}`, name: body?.name ?? "Unnamed" };
|
||||
return Response.json(c, { status: 201 });
|
||||
}
|
||||
|
||||
// GET /v1/collections
|
||||
if (method === "GET" && path === "/v1/collections") {
|
||||
return Response.json({ collections: [] });
|
||||
}
|
||||
|
||||
// POST /v1/collections/:id/features
|
||||
if (method === "POST" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) {
|
||||
const body = (await req.json()) as { geometry?: unknown; properties?: unknown };
|
||||
featureId++;
|
||||
const f = {
|
||||
id: `feat-${featureId}`,
|
||||
geometry: body?.geometry ?? { type: "Point", coordinates: [0, 0] },
|
||||
properties: body?.properties ?? {},
|
||||
};
|
||||
return Response.json(f, { status: 201 });
|
||||
}
|
||||
|
||||
// GET /v1/collections/:id/features
|
||||
if (method === "GET" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) {
|
||||
return Response.json({ features: [] });
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
const url = `http://localhost:${server.port}`;
|
||||
return { url, server };
|
||||
}
|
||||
|
||||
describe("GeoApiClient integration (docs flow)", () => {
|
||||
let mock: { url: string; server: ReturnType<typeof Bun.serve> };
|
||||
let client: GeoApiClient;
|
||||
let storage: MemoryStorage;
|
||||
|
||||
beforeAll(async () => {
|
||||
mock = await createMockServer();
|
||||
storage = new MemoryStorage();
|
||||
client = new GeoApiClient(mock.url, storage);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mock.server.stop();
|
||||
});
|
||||
|
||||
test("getServicePublicKey fetches server public key", async () => {
|
||||
const { publicKey } = await client.getServicePublicKey();
|
||||
expect(publicKey.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test("full flow: ensureKeys -> register -> login -> createCollection -> createPointFeature -> listFeatures", async () => {
|
||||
const keys = await client.ensureKeysInStorage();
|
||||
expect(keys.publicKey).toBeDefined();
|
||||
expect(keys.privateKey).toBeDefined();
|
||||
|
||||
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
|
||||
|
||||
const token = await client.loginWithSignature(keys.publicKey, keys.privateKey);
|
||||
expect(token.length).toBeGreaterThan(10);
|
||||
client.setAccessToken(token);
|
||||
|
||||
const created = await client.createCollection("My Places");
|
||||
expect(created.id).toBeDefined();
|
||||
expect(created.name).toBe("My Places");
|
||||
|
||||
const feature = await client.createPointFeature(created.id, -16.6291, 28.4636, {
|
||||
name: "Santa Cruz",
|
||||
});
|
||||
expect(feature.id).toBeDefined();
|
||||
|
||||
const { features } = await client.listFeatures(created.id);
|
||||
expect(Array.isArray(features)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user