import { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys"; import { bytesToBase64Url, textToBytes } from "./encoding"; import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage"; import type { AssetKind, AssetRecord, FeatureAsset, InvitationPayload, StorageLike, StoredKeys } from "./types"; type RequestInitLike = Omit & { body?: unknown }; type FeatureProperties = { assets?: FeatureAsset[]; [key: string]: unknown; }; type GeoFeature = { id: string; geometry?: { coordinates?: number[]; }; properties?: FeatureProperties; }; /** * 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://tenerife.baby) * @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) { return existing; } const generated = await generateKeyPair(); saveKeys(this.storage, generated, this.storageKey); 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; } private async request(path: string, init: RequestInitLike = {}): Promise { const headers = new Headers(init.headers ?? {}); headers.set("Content-Type", "application/json"); if (this.accessToken) { headers.set("Authorization", `Bearer ${this.accessToken}`); } 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(() => ({}))) 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) { return undefined as T; } 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); await this.request("/v1/auth/register-by-signature", { method: "POST", body: { publicKey, signature }, }); } /** * 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); const response = await this.request<{ accessToken: string }>("/v1/auth/login", { method: "POST", body: { publicKey, nonce: challenge.nonce, signature, }, }); this.accessToken = response.accessToken; 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)); const inviteSignature = await signMessage(inviterPrivateKey, `invite:${payloadB64}`); await this.request("/v1/invitations", { method: "POST", body: { invitePayloadB64: payloadB64, inviteSignature, }, }); } /** * 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; invitePayloadB64: string; inviteSignature: string; jti: string; }): Promise { const proofSignature = await signMessage(input.privateKey, `register:${input.publicKey}:${input.jti}`); await this.request("/v1/auth/register", { method: "POST", body: { publicKey: input.publicKey, invitePayloadB64: input.invitePayloadB64, inviteSignature: input.inviteSignature, proofSignature, }, }); } /** 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", body: { name }, }); } /** 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: GeoFeature[] }> { 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, lat: number, properties: Record ): Promise<{ id: string }> { return this.request(`/v1/collections/${collectionId}/features`, { method: "POST", body: { geometry: { type: "Point", coordinates: [lon, lat] }, properties, }, }); } /** Delete a feature. Must own it. */ async deleteFeature(featureId: string): Promise { return this.request(`/v1/features/${featureId}`, { method: "DELETE" }); } /** * Create or reuse an asset by checksum+ext for the authenticated owner and link it to a feature. * If checksum/ext already exists for this owner, backend returns existing asset and refreshes link metadata. */ async createOrLinkAsset(input: { featureId: string; checksum: string; ext: string; kind: AssetKind; mimeType?: string; sizeBytes?: number; name?: string; description?: string; isPublic?: boolean; }): Promise<{ asset: AssetRecord; link: string }> { return this.request("/v1/assets", { method: "POST", body: input }); } /** * Request a backend upload URL for an existing asset. * Backend returns a service URL (for example /v1/assets/{id}/upload), not a direct storage endpoint. */ async getAssetSignedUploadUrl( assetId: string, contentType?: string ): Promise<{ url: string; method: string }> { const response = await this.request<{ url: string; method: string }>(`/v1/assets/${assetId}/signed-upload`, { method: "POST", body: { contentType: contentType ?? "application/octet-stream" }, }); if (response.url.startsWith("/")) { response.url = this.resolveRelativeLink(response.url); } return response; } /** * Upload file/binary for an existing asset through backend upload endpoint. * Uses getAssetSignedUploadUrl internally and executes the upload request. */ async uploadAssetBinary( assetId: string, payload: BodyInit, contentType = "application/octet-stream" ): Promise { const upload = await this.getAssetSignedUploadUrl(assetId, contentType); const headers = new Headers(); if (contentType) { headers.set("Content-Type", contentType); } if (this.accessToken) { headers.set("Authorization", `Bearer ${this.accessToken}`); } const res = await fetch(upload.url, { method: upload.method || "PUT", headers, body: payload, }); if (!res.ok) { const maybeJson = (await res.json().catch(() => ({}))) as { error?: string; hint?: string }; let msg = maybeJson.error ?? `Upload failed (${res.status})`; if (maybeJson.hint) msg += `. ${maybeJson.hint}`; throw new Error(msg); } } /** Update asset visibility (owner only). */ async setAssetVisibility(assetId: string, isPublic: boolean): Promise<{ asset: AssetRecord; link: string }> { return this.request(`/v1/assets/${assetId}`, { method: "PATCH", body: { isPublic }, }); } /** Build absolute download URL from service-relative link returned in feature assets. */ resolveRelativeLink(path: string): string { if (!path.startsWith("/")) return `${this.baseUrl}/${path}`; return `${this.baseUrl}${path}`; } }