From ef3957b6184952ea8162f7e2c597d29cf0299c4f Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 1 Mar 2026 13:41:54 +0000 Subject: [PATCH] Demo app, collections/features CRUD, QR codes, docs Demo app (web/): - Collections: select, rename, remove (x button per row), delete - Features: add point (lon/lat validation), remove, list in selected collection - QR codes for pk (private) and pb (public) keys - Restore public key from private key - 409 Conflict handled for already-registered login - Title: Momswap Geo Backend Use-Cases Test Backend: - PATCH /v1/collections/{id} for rename - DELETE /v1/collections/{id} - Clearer lon/lat validation errors (-180..180, -90..90) Client: - updateCollection, deleteCollection, derivePublicKey Docs: - docs/frontend-development.md (demo app, local dev) - README links to all docs Made-with: Cursor --- README.md | 14 +- docs/frontend-development.md | 70 +++++++++ docs/typescript-frontend-integration.md | 6 + internal/app/service.go | 34 ++++- internal/http/handlers.go | 38 +++++ internal/store/interface.go | 1 + internal/store/memory.go | 15 ++ internal/store/postgres.go | 12 ++ libs/geo-api-client/dist/index.js | 25 +++- libs/geo-api-client/src/GeoApiClient.ts | 21 ++- libs/geo-api-client/src/index.ts | 2 +- libs/geo-api-client/src/keys.ts | 6 + web/app.js | 189 +++++++++++++++++++++++- web/index.html | 84 +++++++++-- web/qr.js | 21 +++ 15 files changed, 512 insertions(+), 26 deletions(-) create mode 100644 docs/frontend-development.md create mode 100644 web/qr.js diff --git a/README.md b/README.md index 108c8bb..ad8f5c6 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,15 @@ Then visit: - Production: `https://momswap.produktor.duckdns.org/web/` - Local: `http://localhost:8122/web/` +## Documentation + +| Doc | Description | +|-----|-------------| +| [docs/frontend-development.md](docs/frontend-development.md) | Demo app (`web/`), client build, local dev | +| [docs/typescript-frontend-integration.md](docs/typescript-frontend-integration.md) | TypeScript client API, integration flow, examples | +| [docs/ed25519-security-use-cases.md](docs/ed25519-security-use-cases.md) | Ed25519 auth flows, registration, signatures | +| [docs/geo-auth-backend-plan.md](docs/geo-auth-backend-plan.md) | Architecture and planning | + ## API client library Path: `libs/geo-api-client` @@ -99,11 +108,6 @@ bun test bun run build ``` -Frontend TypeScript integration guide: - -- `docs/typescript-frontend-integration.md` -- `docs/ed25519-security-use-cases.md` - ## CI Workflow: `.gitea/workflows/ci.yml` diff --git a/docs/frontend-development.md b/docs/frontend-development.md new file mode 100644 index 0000000..3cb532b --- /dev/null +++ b/docs/frontend-development.md @@ -0,0 +1,70 @@ +# Frontend Development + +Development guide for the Momswap Geo demo app (`web/`) and TypeScript client (`libs/geo-api-client`). + +## Demo app (`web/`) + +Vue 3 + Vuetify 3 single-page app, no bundler. Served by the backend at `/web/`. + +### Structure + +``` +web/ +├── index.html # Entry page, Vue/Vuetify from CDN +├── app.js # Vue app, state, handlers +├── api.js # GeoApiClient wrapper for browser +└── qr.js # QR code generation (pk/pb keys) +``` + +### Running locally + +1. Start the API: + ```bash + go run ./cmd/api + # or: docker compose up -d + ``` +2. Open `http://localhost:8122/web/` + +### Dependencies + +- Vue 3 and Vuetify 3 from CDN (no npm install in `web/`) +- `libs/geo-api-client/dist/index.js` — built ESM client +- `qr.js` — imports `qrcode` from esm.sh + +### Build step for client + +The demo app uses the pre-built client. After changing `libs/geo-api-client`: + +```bash +cd libs/geo-api-client +bun run build +``` + +With Docker, the image build runs this automatically. + +### Features (use-cases test) + +- Connection & Identity: API URL, key generation, pk/pb display, QR codes, restore pb from pk, register, login +- Collections: create, select, rename, remove +- Features: add point (lon/lat validation -180..180, -90..90), remove, list + +## TypeScript client (`libs/geo-api-client`) + +Reusable API client with Ed25519 signing. See [TypeScript Frontend Integration](typescript-frontend-integration.md) for full API and integration flow. + +### Build & test + +```bash +cd libs/geo-api-client +bun install +bun test +bun run build +``` + +## Related docs + +| Document | Description | +|----------|-------------| +| [TypeScript Frontend Integration](typescript-frontend-integration.md) | API client usage, integration flow, examples | +| [Ed25519 Security Use Cases](ed25519-security-use-cases.md) | Auth flows, registration, signatures | +| [Geo Auth Backend Plan](geo-auth-backend-plan.md) | Architecture and planning | diff --git a/docs/typescript-frontend-integration.md b/docs/typescript-frontend-integration.md index 0b0b5f7..2217a75 100644 --- a/docs/typescript-frontend-integration.md +++ b/docs/typescript-frontend-integration.md @@ -2,6 +2,8 @@ This document explains how frontend developers should integrate with the backend through the reusable TypeScript client at `libs/geo-api-client`. +> **See also:** [Frontend Development](frontend-development.md) — demo app (`web/`), local dev, build steps. + Primary backend URL for integration: - `https://momswap.produktor.duckdns.org/` @@ -43,6 +45,7 @@ Key methods: - `ensureKeysInStorage()` - `getStoredKeys()` +- `derivePublicKey(privateKey)` — restore public key from private key (Ed25519) - `importKeys(keys)` - `exportKeys()` - `setAccessToken(token)` @@ -54,8 +57,11 @@ Key methods: - `registerWithInvitation(...)` - `listCollections()` - `createCollection(name)` +- `updateCollection(collectionId, name)` +- `deleteCollection(collectionId)` - `listFeatures(collectionId)` - `createPointFeature(collectionId, lon, lat, properties)` +- `deleteFeature(featureId)` ## Recommended integration flow diff --git a/internal/app/service.go b/internal/app/service.go index 75f37a4..6fdce13 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -244,8 +244,11 @@ func validatePoint(point store.Point) error { return fmt.Errorf("%w: coordinates must have lon/lat", ErrBadRequest) } lon, lat := point.Coordinates[0], point.Coordinates[1] - if lon < -180 || lon > 180 || lat < -90 || lat > 90 { - return fmt.Errorf("%w: invalid lon/lat bounds", ErrBadRequest) + if lon < -180 || lon > 180 { + return fmt.Errorf("%w: longitude must be -180 to 180, got %.2f", ErrBadRequest, lon) + } + if lat < -90 || lat > 90 { + return fmt.Errorf("%w: latitude must be -90 to 90, got %.2f", ErrBadRequest, lat) } return nil } @@ -272,6 +275,33 @@ func (s *Service) ListCollections(ownerKey string) []store.Collection { return s.store.ListCollectionsByOwner(ownerKey) } +func (s *Service) UpdateCollection(ownerKey, collectionID, name string) (store.Collection, error) { + collection, err := s.store.GetCollection(collectionID) + if err != nil { + return store.Collection{}, ErrCollectionMiss + } + if collection.OwnerKey != ownerKey { + return store.Collection{}, ErrForbidden + } + if name == "" { + return store.Collection{}, fmt.Errorf("%w: collection name required", ErrBadRequest) + } + collection.Name = name + s.store.SaveCollection(collection) + return collection, nil +} + +func (s *Service) DeleteCollection(ownerKey, collectionID string) error { + collection, err := s.store.GetCollection(collectionID) + if err != nil { + return ErrCollectionMiss + } + if collection.OwnerKey != ownerKey { + return ErrForbidden + } + return s.store.DeleteCollection(collectionID) +} + func (s *Service) CreateFeature(ownerKey, collectionID string, geometry store.Point, properties map[string]interface{}) (store.Feature, error) { collection, err := s.store.GetCollection(collectionID) if err != nil { diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 8511657..a67bc1f 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -35,6 +35,8 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("POST /v1/collections", a.createCollection) mux.HandleFunc("GET /v1/collections", a.listCollections) + mux.HandleFunc("PATCH /v1/collections/{id}", a.updateCollection) + mux.HandleFunc("DELETE /v1/collections/{id}", a.deleteCollection) mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature) mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures) mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature) @@ -272,6 +274,42 @@ func (a *API) listCollections(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"collections": a.service.ListCollections(user)}) } +func (a *API) updateCollection(w http.ResponseWriter, r *http.Request) { + user, err := a.authUser(r) + if err != nil { + writeErr(w, err) + return + } + collectionID := r.PathValue("id") + var req struct { + Name string `json:"name"` + } + if err := readJSON(r, &req); err != nil { + writeErr(w, app.ErrBadRequest) + return + } + collection, err := a.service.UpdateCollection(user, collectionID, req.Name) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusOK, collection) +} + +func (a *API) deleteCollection(w http.ResponseWriter, r *http.Request) { + user, err := a.authUser(r) + if err != nil { + writeErr(w, err) + return + } + collectionID := r.PathValue("id") + if err := a.service.DeleteCollection(user, collectionID); err != nil { + writeErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (a *API) createFeature(w http.ResponseWriter, r *http.Request) { user, err := a.authUser(r) if err != nil { diff --git a/internal/store/interface.go b/internal/store/interface.go index b9bb4b3..2955ea1 100644 --- a/internal/store/interface.go +++ b/internal/store/interface.go @@ -16,6 +16,7 @@ type Store interface { SaveCollection(c Collection) ListCollectionsByOwner(owner string) []Collection GetCollection(id string) (Collection, error) + DeleteCollection(id string) error SaveFeature(f Feature) ListFeaturesByCollection(collectionID string) []Feature GetFeature(featureID string) (Feature, error) diff --git a/internal/store/memory.go b/internal/store/memory.go index 1f8f662..c1cb474 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -157,6 +157,21 @@ func (s *MemoryStore) GetCollection(id string) (Collection, error) { return c, nil } +func (s *MemoryStore) DeleteCollection(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.collections[id]; !ok { + return ErrNotFound + } + for fid, f := range s.features { + if f.CollectionID == id { + delete(s.features, fid) + } + } + delete(s.collections, id) + return nil +} + func (s *MemoryStore) SaveFeature(f Feature) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 57c217c..d145377 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -204,6 +204,18 @@ func (s *PostgresStore) GetCollection(id string) (Collection, error) { return c, nil } +func (s *PostgresStore) DeleteCollection(id string) error { + res, err := s.db.Exec(`DELETE FROM collections WHERE id = $1`, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + func (s *PostgresStore) SaveFeature(f Feature) { geom, _ := json.Marshal(f.Geometry) props, _ := json.Marshal(f.Properties) diff --git a/libs/geo-api-client/dist/index.js b/libs/geo-api-client/dist/index.js index 16baa49..7bb9ae4 100644 --- a/libs/geo-api-client/dist/index.js +++ b/libs/geo-api-client/dist/index.js @@ -470,6 +470,11 @@ async function generateKeyPair() { privateKey: bytesToBase64Url(privateKey) }; } +async function getPublicKeyFromPrivate(privateKeyBase64) { + const privateKey = base64UrlToBytes(privateKeyBase64); + const publicKey = await getPublicKeyAsync(privateKey); + return bytesToBase64Url(publicKey); +} async function signMessage(privateKeyBase64, message) { const privateKey = base64UrlToBytes(privateKeyBase64); const signature = await signAsync(textToBytes(message), privateKey); @@ -519,6 +524,9 @@ class GeoApiClient { getStoredKeys() { return loadKeys(this.storage, this.storageKey); } + async derivePublicKey(privateKey) { + return getPublicKeyFromPrivate(privateKey); + } importKeys(keys) { saveKeys(this.storage, keys, this.storageKey); } @@ -538,7 +546,9 @@ class GeoApiClient { const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, body }); if (!res.ok) { const maybeJson = await res.json().catch(() => ({})); - const msg = maybeJson.error ?? `HTTP ${res.status}`; + let msg = maybeJson.error ?? `HTTP ${res.status}`; + if (maybeJson.hint) + msg += `. ${maybeJson.hint}`; throw new Error(msg); } if (res.status === 204) { @@ -604,6 +614,15 @@ class GeoApiClient { async createCollection(name) { return this.request("/v1/collections", { method: "POST", body: { name } }); } + async updateCollection(collectionId, name) { + return this.request(`/v1/collections/${collectionId}`, { + method: "PATCH", + body: { name } + }); + } + async deleteCollection(collectionId) { + return this.request(`/v1/collections/${collectionId}`, { method: "DELETE" }); + } async listFeatures(collectionId) { return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" }); } @@ -616,11 +635,15 @@ class GeoApiClient { } }); } + async deleteFeature(featureId) { + return this.request(`/v1/features/${featureId}`, { method: "DELETE" }); + } } export { signMessage, saveKeys, loadKeys, + getPublicKeyFromPrivate, generateKeyPair, clearKeys, GeoApiClient, diff --git a/libs/geo-api-client/src/GeoApiClient.ts b/libs/geo-api-client/src/GeoApiClient.ts index 1ee7c53..8855ae9 100644 --- a/libs/geo-api-client/src/GeoApiClient.ts +++ b/libs/geo-api-client/src/GeoApiClient.ts @@ -1,4 +1,4 @@ -import { generateKeyPair, signMessage } from "./keys"; +import { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys"; import { bytesToBase64Url, textToBytes } from "./encoding"; import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage"; import type { InvitationPayload, StorageLike, StoredKeys } from "./types"; @@ -31,6 +31,10 @@ export class GeoApiClient { return loadKeys(this.storage, this.storageKey); } + async derivePublicKey(privateKey: string): Promise { + return getPublicKeyFromPrivate(privateKey); + } + importKeys(keys: StoredKeys): void { saveKeys(this.storage, keys, this.storageKey); } @@ -135,6 +139,17 @@ export class GeoApiClient { return this.request("/v1/collections", { method: "POST", body: { name } }); } + async updateCollection(collectionId: string, name: string): Promise<{ id: string; name: string }> { + return this.request(`/v1/collections/${collectionId}`, { + method: "PATCH", + body: { name }, + }); + } + + async deleteCollection(collectionId: string): Promise { + return this.request(`/v1/collections/${collectionId}`, { method: "DELETE" }); + } + async listFeatures(collectionId: string): Promise<{ features: unknown[] }> { return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" }); } @@ -153,4 +168,8 @@ export class GeoApiClient { }, }); } + + async deleteFeature(featureId: string): Promise { + return this.request(`/v1/features/${featureId}`, { method: "DELETE" }); + } } diff --git a/libs/geo-api-client/src/index.ts b/libs/geo-api-client/src/index.ts index bb2305f..0abfdeb 100644 --- a/libs/geo-api-client/src/index.ts +++ b/libs/geo-api-client/src/index.ts @@ -1,4 +1,4 @@ export { GeoApiClient } from "./GeoApiClient"; -export { generateKeyPair, signMessage } from "./keys"; +export { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys"; export { clearKeys, loadKeys, saveKeys, DEFAULT_KEYS_STORAGE_KEY } from "./storage"; export type { InvitationPayload, StorageLike, StoredKeys } from "./types"; diff --git a/libs/geo-api-client/src/keys.ts b/libs/geo-api-client/src/keys.ts index 75d8480..c47ce7c 100644 --- a/libs/geo-api-client/src/keys.ts +++ b/libs/geo-api-client/src/keys.ts @@ -17,6 +17,12 @@ export async function generateKeyPair(): Promise { }; } +export async function getPublicKeyFromPrivate(privateKeyBase64: string): Promise { + const privateKey = base64UrlToBytes(privateKeyBase64); + const publicKey = await getPublicKeyAsync(privateKey); + return bytesToBase64Url(publicKey); +} + export async function signMessage(privateKeyBase64: string, message: string): Promise { const privateKey = base64UrlToBytes(privateKeyBase64); const signature = await signAsync(textToBytes(message), privateKey); diff --git a/web/app.js b/web/app.js index 268880a..e43bfaf 100644 --- a/web/app.js +++ b/web/app.js @@ -1,6 +1,7 @@ import { createApiClient } from "./api.js"; +import { toDataURL } from "./qr.js"; -const { createApp, ref, reactive, onMounted } = Vue; +const { createApp, ref, reactive, onMounted, watch } = Vue; createApp({ setup() { @@ -11,7 +12,16 @@ createApp({ accessToken: "", collections: [], newCollectionName: "", + selectedCollectionId: "", + editingCollectionName: "", + featuresByCollection: {}, + newFeatureLon: "", + newFeatureLat: "", + newFeatureName: "", status: "Ready", + qrPk: "", + qrPb: "", + showPrivateQR: false, }); let client = createApiClient(apiBase.value); @@ -22,12 +32,48 @@ createApp({ state.status = `API base set to ${apiBase.value}`; }; + const refreshQRCodes = async () => { + if (!state.publicKey) { + state.qrPk = ""; + state.qrPb = ""; + return; + } + try { + state.qrPb = await toDataURL(state.publicKey); + if (state.privateKey) { + state.qrPk = await toDataURL(state.privateKey, { dark: "#7f1d1d" }); + } else { + state.qrPb = ""; + } + } catch (err) { + state.qrPk = ""; + state.qrPb = ""; + } + }; + const ensureKeys = async () => { try { const keys = await client.ensureKeysInStorage(); state.publicKey = keys.publicKey; state.privateKey = keys.privateKey; state.status = "Keys loaded from localStorage."; + await refreshQRCodes(); + } catch (err) { + state.status = err.message; + } + }; + + const restorePublicKeyFromPrivate = async () => { + if (!state.privateKey?.trim()) { + state.status = "Enter private key (pk) first."; + return; + } + try { + const pb = await client.derivePublicKey(state.privateKey.trim()); + state.publicKey = pb; + client.importKeys({ publicKey: pb, privateKey: state.privateKey.trim() }); + await refreshQRCodes(); + state.status = "Public key (pb) restored from private key (pk)."; } catch (err) { state.status = err.message; } @@ -47,13 +93,15 @@ createApp({ try { await client.registerBySigningServiceKey(state.publicKey, state.privateKey); } catch (err) { - if (!err.message.includes("already registered") && !err.message.includes("not configured") && !err.message.includes("ADMIN_PUBLIC_KEY")) { - throw err; - } + const msg = (err?.message || "").toLowerCase(); + const ignore = msg.includes("already registered") || msg.includes("not configured") || + msg.includes("admin_public_key") || msg.includes("409") || msg.includes("conflict"); + if (!ignore) throw err; // Proceed to login: already registered or registration disabled (invitation flow) } state.accessToken = await client.loginWithSignature(state.publicKey, state.privateKey); client.setAccessToken(state.accessToken); + await listCollections(); state.status = "Authenticated."; } catch (err) { state.status = err.message; @@ -81,19 +129,152 @@ createApp({ } }; + const removeCollection = async (collectionId) => { + try { + client.setAccessToken(state.accessToken); + await client.deleteCollection(collectionId); + if (state.selectedCollectionId === collectionId) { + state.selectedCollectionId = ""; + } + delete state.featuresByCollection[collectionId]; + await listCollections(); + } catch (err) { + state.status = err.message; + } + }; + + const listFeatures = async (collectionId) => { + if (!collectionId) return; + try { + client.setAccessToken(state.accessToken); + const data = await client.listFeatures(collectionId); + state.featuresByCollection[collectionId] = data.features || []; + } catch (err) { + state.status = err.message; + } + }; + + const createFeature = async (collectionId) => { + if (!collectionId) return; + const lon = parseFloat(state.newFeatureLon); + const lat = parseFloat(state.newFeatureLat); + if (isNaN(lon) || isNaN(lat)) { + state.status = "Enter valid lon/lat numbers."; + return; + } + if (lon < -180 || lon > 180) { + state.status = "Longitude must be -180 to 180."; + return; + } + if (lat < -90 || lat > 90) { + state.status = "Latitude must be -90 to 90."; + return; + } + try { + client.setAccessToken(state.accessToken); + await client.createPointFeature(collectionId, lon, lat, { + name: state.newFeatureName || "Point", + }); + state.newFeatureLon = ""; + state.newFeatureLat = ""; + state.newFeatureName = ""; + await listFeatures(collectionId); + } catch (err) { + state.status = err.message; + } + }; + + const removeFeature = async (collectionId, featureId) => { + try { + client.setAccessToken(state.accessToken); + await client.deleteFeature(featureId); + await listFeatures(collectionId); + } catch (err) { + state.status = err.message; + } + }; + + const featuresFor = (collectionId) => state.featuresByCollection[collectionId] ?? []; + + const formatFeature = (f) => { + const coords = f.geometry?.coordinates; + const lon = coords?.[0] ?? "—"; + const lat = coords?.[1] ?? "—"; + const name = f.properties?.name ?? f.id ?? "—"; + return { id: f.id, name, lon, lat }; + }; + + watch( + () => [state.publicKey, state.privateKey], + () => refreshQRCodes(), + { immediate: false } + ); + + watch( + () => state.selectedCollectionId, + (id) => { + if (id) listFeatures(id); + } + ); + + const selectCollection = (id) => { + state.selectedCollectionId = id; + const c = state.collections.find((x) => x.id === id); + state.editingCollectionName = c ? c.name : ""; + }; + + const selectedCollection = () => + state.collections.find((c) => c.id === state.selectedCollectionId); + + const updateCollectionName = async (collectionId, newName) => { + if (!newName?.trim()) return; + try { + client.setAccessToken(state.accessToken); + const updated = await client.updateCollection(collectionId, newName.trim()); + const idx = state.collections.findIndex((c) => c.id === collectionId); + if (idx >= 0) state.collections[idx] = updated; + state.editingCollectionName = updated.name; + } catch (err) { + state.status = err.message; + } + }; + + const onCollectionNameBlur = () => { + const c = selectedCollection(); + if (c && state.editingCollectionName !== c.name) { + updateCollectionName(c.id, state.editingCollectionName); + } + }; + onMounted(async () => { await ensureKeys(); }); + const togglePrivateQR = () => { + state.showPrivateQR = !state.showPrivateQR; + }; + return { apiBase, state, rebuildClient, ensureKeys, + restorePublicKeyFromPrivate, register, login, listCollections, createCollection, + removeCollection, + listFeatures, + selectCollection, + selectedCollection, + updateCollectionName, + onCollectionNameBlur, + createFeature, + removeFeature, + formatFeature, + featuresFor, + togglePrivateQR, }; }, }).use(Vuetify.createVuetify()).mount("#app"); diff --git a/web/index.html b/web/index.html index 616bf17..181274b 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - Momswap Geo Console + Momswap Geo Backend Use-Cases Test