diff --git a/libs/geo-api-client/src/GeoApiClient.ts b/libs/geo-api-client/src/GeoApiClient.ts index 8855ae9..4bf024c 100644 --- a/libs/geo-api-client/src/GeoApiClient.ts +++ b/libs/geo-api-client/src/GeoApiClient.ts @@ -5,18 +5,31 @@ import type { InvitationPayload, StorageLike, StoredKeys } from "./types"; type RequestInitLike = Omit & { body?: unknown }; +/** + * TypeScript API client for Momswap Geo backend. + * Handles Ed25519 key storage, auth flows, and GeoJSON collection/feature CRUD. + */ export class GeoApiClient { private readonly baseUrl: string; private readonly storage: StorageLike; private readonly storageKey: string; private accessToken: string | null = null; + /** + * @param baseUrl - API base URL (e.g. https://momswap.produktor.duckdns.org) + * @param storage - Storage adapter (localStorage-like: getItem, setItem, removeItem) + * @param storageKey - Key for persisting keypair (default from DEFAULT_KEYS_STORAGE_KEY) + */ constructor(baseUrl: string, storage: StorageLike, storageKey = DEFAULT_KEYS_STORAGE_KEY) { this.baseUrl = baseUrl.replace(/\/+$/g, ""); this.storage = storage; this.storageKey = storageKey; } + /** + * Ensure a keypair exists in storage. If none found, generates a new Ed25519 keypair and saves it. + * Use on app init so the user always has keys ready for auth. + */ async ensureKeysInStorage(): Promise { const existing = loadKeys(this.storage, this.storageKey); if (existing) { @@ -27,22 +40,30 @@ export class GeoApiClient { return generated; } + /** Get the stored keypair without generating. Returns null if none stored. */ getStoredKeys(): StoredKeys | null { return loadKeys(this.storage, this.storageKey); } + /** + * Derive public key from private key (Ed25519). + * Use when importing a private key from backup/QR to restore the public key. + */ async derivePublicKey(privateKey: string): Promise { return getPublicKeyFromPrivate(privateKey); } + /** Overwrite stored keypair (e.g. after import from backup or restore from QR). */ importKeys(keys: StoredKeys): void { saveKeys(this.storage, keys, this.storageKey); } + /** Read stored keypair for export/backup. Does not modify storage. */ exportKeys(): StoredKeys | null { return loadKeys(this.storage, this.storageKey); } + /** Set bearer token for authenticated requests. Call after loginWithSignature. */ setAccessToken(token: string | null): void { this.accessToken = token; } @@ -67,14 +88,26 @@ export class GeoApiClient { return (await res.json()) as T; } + /** + * Fetch the API service public key. Used for registerBySigningServiceKey + * and for clients that need to verify server responses. + */ async getServicePublicKey(): Promise<{ publicKey: string }> { return this.request("/v1/service-key", { method: "GET" }); } + /** + * Request a login challenge. Returns nonce and messageToSign. + * Used internally by loginWithSignature; or for custom login flows. + */ async createChallenge(publicKey: string): Promise<{ nonce: string; messageToSign: string }> { return this.request("/v1/auth/challenge", { method: "POST", body: { publicKey } }); } + /** + * Register without invitation by signing the API service public key. + * Use when SERVICE_PUBLIC_KEY/ADMIN_PUBLIC_KEY is set. Throws 409 if already registered. + */ async registerBySigningServiceKey(publicKey: string, privateKey: string): Promise { const { publicKey: servicePublicKey } = await this.getServicePublicKey(); const signature = await signMessage(privateKey, servicePublicKey); @@ -84,6 +117,11 @@ export class GeoApiClient { }); } + /** + * Login via challenge-response. Signs the login nonce, returns bearer token. + * Stores token internally; call setAccessToken if you need it elsewhere. + * Requires user to be registered first (registerBySigningServiceKey or registerWithInvitation). + */ async loginWithSignature(publicKey: string, privateKey: string): Promise { const challenge = await this.createChallenge(publicKey); const signature = await signMessage(privateKey, challenge.messageToSign); @@ -99,6 +137,10 @@ export class GeoApiClient { return response.accessToken; } + /** + * Create an invitation for new users. Authenticated inviter signs the payload. + * Use when invitation-based onboarding is required (no register-by-signature). + */ async createInvitation(payload: InvitationPayload, inviterPrivateKey: string): Promise { const payloadStr = JSON.stringify(payload); const payloadB64 = bytesToBase64Url(textToBytes(payloadStr)); @@ -112,6 +154,10 @@ export class GeoApiClient { }); } + /** + * Register using an invitation. Proves ownership of keys and redeems the invite. + * Use when user has received an invitation (e.g. from createInvitation). + */ async registerWithInvitation(input: { publicKey: string; privateKey: string; @@ -131,14 +177,17 @@ export class GeoApiClient { }); } + /** List collections for the authenticated user. Requires login first. */ async listCollections(): Promise<{ collections: Array<{ id: string; name: string }> }> { return this.request("/v1/collections", { method: "GET" }); } + /** Create a new collection. Returns id and name. */ async createCollection(name: string): Promise<{ id: string; name: string }> { return this.request("/v1/collections", { method: "POST", body: { name } }); } + /** Rename a collection. Must own it. */ async updateCollection(collectionId: string, name: string): Promise<{ id: string; name: string }> { return this.request(`/v1/collections/${collectionId}`, { method: "PATCH", @@ -146,14 +195,20 @@ export class GeoApiClient { }); } + /** Delete a collection and its features. Must own it. */ async deleteCollection(collectionId: string): Promise { return this.request(`/v1/collections/${collectionId}`, { method: "DELETE" }); } + /** List GeoJSON features in a collection. Must own the collection. */ async listFeatures(collectionId: string): Promise<{ features: unknown[] }> { return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" }); } + /** + * Add a Point feature to a collection. lon ∈ [-180,180], lat ∈ [-90,90]. + * properties can include name, etc. Returns the new feature id. + */ async createPointFeature( collectionId: string, lon: number, @@ -169,6 +224,7 @@ export class GeoApiClient { }); } + /** Delete a feature. Must own it. */ async deleteFeature(featureId: string): Promise { return this.request(`/v1/features/${featureId}`, { method: "DELETE" }); }