diff --git a/AGENTS.md b/AGENTS.md index eca79a5..5a07cd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` diff --git a/README.md b/README.md index f2a0834..108c8bb 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cmd/api/main.go b/cmd/api/main.go index dac8b8b..9aa1c20 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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) diff --git a/docs/typescript-frontend-integration.md b/docs/typescript-frontend-integration.md index 4a51ec1..0b0b5f7 100644 --- a/docs/typescript-frontend-integration.md +++ b/docs/typescript-frontend-integration.md @@ -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"); diff --git a/go.mod b/go.mod index 1727ac3..72c1962 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2aca163 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/app/service.go b/internal/app/service.go index 48e3e66..75f37a4 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -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 { diff --git a/internal/http/handlers.go b/internal/http/handlers.go index b472abb..8511657 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -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) { diff --git a/internal/store/interface.go b/internal/store/interface.go new file mode 100644 index 0000000..b9bb4b3 --- /dev/null +++ b/internal/store/interface.go @@ -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) +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..f2c136f --- /dev/null +++ b/internal/store/migrate.go @@ -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 +} diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql new file mode 100644 index 0000000..3b7f61e --- /dev/null +++ b/internal/store/migrations/0001_init.sql @@ -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); diff --git a/internal/store/postgres.go b/internal/store/postgres.go new file mode 100644 index 0000000..57c217c --- /dev/null +++ b/internal/store/postgres.go @@ -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") +} diff --git a/libs/geo-api-client/src/GeoApiClient.ts b/libs/geo-api-client/src/GeoApiClient.ts index 7f34670..1ee7c53 100644 --- a/libs/geo-api-client/src/GeoApiClient.ts +++ b/libs/geo-api-client/src/GeoApiClient.ts @@ -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) { diff --git a/libs/geo-api-client/test/integration.test.ts b/libs/geo-api-client/test/integration.test.ts new file mode 100644 index 0000000..27f4631 --- /dev/null +++ b/libs/geo-api-client/test/integration.test.ts @@ -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(); + 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 }> { + const serverKeypair = await generateKeyPair(); + const users = new Set(); + const sessions = new Map(); + 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 }; + 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); + }); +});