Demo app, collections/features CRUD, QR codes, docs
CI / test (push) Successful in 4s

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
This commit is contained in:
2026-03-01 13:41:54 +00:00
parent ceeac1a1ee
commit ef3957b618
15 changed files with 512 additions and 26 deletions
+24 -1
View File
@@ -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,
+20 -1
View File
@@ -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<string> {
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<void> {
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<void> {
return this.request(`/v1/features/${featureId}`, { method: "DELETE" });
}
}
+1 -1
View File
@@ -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";
+6
View File
@@ -17,6 +17,12 @@ export async function generateKeyPair(): Promise<StoredKeys> {
};
}
export async function getPublicKeyFromPrivate(privateKeyBase64: string): Promise<string> {
const privateKey = base64UrlToBytes(privateKeyBase64);
const publicKey = await getPublicKeyAsync(privateKey);
return bytesToBase64Url(publicKey);
}
export async function signMessage(privateKeyBase64: string, message: string): Promise<string> {
const privateKey = base64UrlToBytes(privateKeyBase64);
const signature = await signAsync(textToBytes(message), privateKey);