- 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`
|
- API entrypoint: `cmd/api/main.go`
|
||||||
- HTTP routes/handlers: `internal/http/handlers.go`
|
- HTTP routes/handlers: `internal/http/handlers.go`
|
||||||
- Core domain logic: `internal/app/service.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/`
|
- Auth utilities: `internal/auth/`
|
||||||
- Frontend static app: `web/`
|
- Frontend static app: `web/`
|
||||||
- TypeScript API client: `libs/geo-api-client/`
|
- 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:
|
This starts:
|
||||||
|
|
||||||
- `db` (`postgis/postgis`) on `5432`
|
- `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:
|
Stop the service:
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -27,8 +27,22 @@ func main() {
|
|||||||
servicePublicKey = adminPublicKey
|
servicePublicKey = adminPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
memory := store.NewMemoryStore()
|
var st store.Store
|
||||||
service := app.NewService(memory, app.Config{
|
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,
|
ChallengeTTL: 5 * time.Minute,
|
||||||
SessionTTL: 24 * time.Hour,
|
SessionTTL: 24 * time.Hour,
|
||||||
}, servicePublicKey)
|
}, servicePublicKey)
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ Deployment: API is proxied via reverse proxy from `https://momswap.produktor.duc
|
|||||||
```bash
|
```bash
|
||||||
cd libs/geo-api-client
|
cd libs/geo-api-client
|
||||||
bun install
|
bun install
|
||||||
bun test
|
bun test # unit + integration tests (docs flow)
|
||||||
bun run build
|
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)
|
## Public API (current)
|
||||||
|
|
||||||
### Class: `GeoApiClient`
|
### 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";
|
import { GeoApiClient } from "../libs/geo-api-client/dist/index.js";
|
||||||
|
|
||||||
const storage = window.localStorage;
|
const storage = window.localStorage;
|
||||||
|
|
||||||
const storageLike = {
|
const storageLike = {
|
||||||
getItem: (key: string) => storage.getItem(key),
|
getItem: (key: string) => storage.getItem(key),
|
||||||
setItem: (key: string, value: string) => storage.setItem(key, value),
|
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 client = new GeoApiClient("https://momswap.produktor.duckdns.org", storageLike);
|
||||||
|
|
||||||
const keys = await client.ensureKeysInStorage();
|
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);
|
await client.loginWithSignature(keys.publicKey, keys.privateKey);
|
||||||
|
|
||||||
const created = await client.createCollection("My Places");
|
const created = await client.createCollection("My Places");
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
module momswap/backend
|
module momswap/backend
|
||||||
|
|
||||||
go 1.25
|
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")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrForbidden = errors.New("forbidden")
|
ErrForbidden = errors.New("forbidden")
|
||||||
ErrBadRequest = errors.New("bad request")
|
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")
|
ErrInviteInvalid = errors.New("invite invalid")
|
||||||
ErrInviteExpired = errors.New("invite expired")
|
ErrInviteExpired = errors.New("invite expired")
|
||||||
ErrInviteExhaust = errors.New("invite exhausted")
|
ErrInviteExhaust = errors.New("invite exhausted")
|
||||||
@@ -30,13 +30,13 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *store.MemoryStore
|
store store.Store
|
||||||
config Config
|
config Config
|
||||||
servicePublicKey string
|
servicePublicKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(memoryStore *store.MemoryStore, cfg Config, servicePublicKey string) *Service {
|
func NewService(st store.Store, cfg Config, servicePublicKey string) *Service {
|
||||||
return &Service{store: memoryStore, config: cfg, servicePublicKey: servicePublicKey}
|
return &Service{store: st, config: cfg, servicePublicKey: servicePublicKey}
|
||||||
}
|
}
|
||||||
|
|
||||||
type InvitationPayload struct {
|
type InvitationPayload struct {
|
||||||
|
|||||||
@@ -103,7 +103,12 @@ func statusFromErr(err error) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, err error) {
|
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) {
|
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 body = init.body === undefined ? undefined : JSON.stringify(init.body);
|
||||||
const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, body });
|
const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, body });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const maybeJson = await res.json().catch(() => ({}));
|
const maybeJson = (await res.json().catch(() => ({}))) as { error?: string; hint?: string; code?: string };
|
||||||
const msg = (maybeJson as { error?: string }).error ?? `HTTP ${res.status}`;
|
let msg = maybeJson.error ?? `HTTP ${res.status}`;
|
||||||
|
if (maybeJson.hint) msg += `. ${maybeJson.hint}`;
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
if (res.status === 204) {
|
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