Files
backend/libs/geo-api-client/src/GeoApiClient.ts
Andriy Oblivantsev a666f1233d
CI / test (push) Successful in 5s
Refresh docs and client for backend-routed asset uploads.
This updates developer docs and web demos to use backend upload endpoints, adds a client upload helper, and aligns integration tests with the no-direct-MinIO URL flow.

Made-with: Cursor
2026-03-02 21:51:47 +00:00

325 lines
11 KiB
TypeScript

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<RequestInit, "body"> & { 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<StoredKeys> {
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<string> {
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<T>(path: string, init: RequestInitLike = {}): Promise<T> {
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<void> {
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<string> {
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<void> {
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<void> {
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<void> {
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<string, unknown>
): 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<void> {
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<void> {
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}`;
}
}