Made-with: Cursor
This commit is contained in:
@@ -5,18 +5,31 @@ import type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
|||||||
|
|
||||||
type RequestInitLike = Omit<RequestInit, "body"> & { body?: unknown };
|
type RequestInitLike = Omit<RequestInit, "body"> & { body?: unknown };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeScript API client for Momswap Geo backend.
|
||||||
|
* Handles Ed25519 key storage, auth flows, and GeoJSON collection/feature CRUD.
|
||||||
|
*/
|
||||||
export class GeoApiClient {
|
export class GeoApiClient {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly storage: StorageLike;
|
private readonly storage: StorageLike;
|
||||||
private readonly storageKey: string;
|
private readonly storageKey: string;
|
||||||
private accessToken: string | null = null;
|
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) {
|
constructor(baseUrl: string, storage: StorageLike, storageKey = DEFAULT_KEYS_STORAGE_KEY) {
|
||||||
this.baseUrl = baseUrl.replace(/\/+$/g, "");
|
this.baseUrl = baseUrl.replace(/\/+$/g, "");
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.storageKey = storageKey;
|
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> {
|
async ensureKeysInStorage(): Promise<StoredKeys> {
|
||||||
const existing = loadKeys(this.storage, this.storageKey);
|
const existing = loadKeys(this.storage, this.storageKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -27,22 +40,30 @@ export class GeoApiClient {
|
|||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the stored keypair without generating. Returns null if none stored. */
|
||||||
getStoredKeys(): StoredKeys | null {
|
getStoredKeys(): StoredKeys | null {
|
||||||
return loadKeys(this.storage, this.storageKey);
|
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> {
|
async derivePublicKey(privateKey: string): Promise<string> {
|
||||||
return getPublicKeyFromPrivate(privateKey);
|
return getPublicKeyFromPrivate(privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Overwrite stored keypair (e.g. after import from backup or restore from QR). */
|
||||||
importKeys(keys: StoredKeys): void {
|
importKeys(keys: StoredKeys): void {
|
||||||
saveKeys(this.storage, keys, this.storageKey);
|
saveKeys(this.storage, keys, this.storageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read stored keypair for export/backup. Does not modify storage. */
|
||||||
exportKeys(): StoredKeys | null {
|
exportKeys(): StoredKeys | null {
|
||||||
return loadKeys(this.storage, this.storageKey);
|
return loadKeys(this.storage, this.storageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set bearer token for authenticated requests. Call after loginWithSignature. */
|
||||||
setAccessToken(token: string | null): void {
|
setAccessToken(token: string | null): void {
|
||||||
this.accessToken = token;
|
this.accessToken = token;
|
||||||
}
|
}
|
||||||
@@ -67,14 +88,26 @@ export class GeoApiClient {
|
|||||||
return (await res.json()) 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 }> {
|
async getServicePublicKey(): Promise<{ publicKey: string }> {
|
||||||
return this.request("/v1/service-key", { method: "GET" });
|
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 }> {
|
async createChallenge(publicKey: string): Promise<{ nonce: string; messageToSign: string }> {
|
||||||
return this.request("/v1/auth/challenge", { method: "POST", body: { publicKey } });
|
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> {
|
async registerBySigningServiceKey(publicKey: string, privateKey: string): Promise<void> {
|
||||||
const { publicKey: servicePublicKey } = await this.getServicePublicKey();
|
const { publicKey: servicePublicKey } = await this.getServicePublicKey();
|
||||||
const signature = await signMessage(privateKey, servicePublicKey);
|
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<string> {
|
async loginWithSignature(publicKey: string, privateKey: string): Promise<string> {
|
||||||
const challenge = await this.createChallenge(publicKey);
|
const challenge = await this.createChallenge(publicKey);
|
||||||
const signature = await signMessage(privateKey, challenge.messageToSign);
|
const signature = await signMessage(privateKey, challenge.messageToSign);
|
||||||
@@ -99,6 +137,10 @@ export class GeoApiClient {
|
|||||||
return 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> {
|
async createInvitation(payload: InvitationPayload, inviterPrivateKey: string): Promise<void> {
|
||||||
const payloadStr = JSON.stringify(payload);
|
const payloadStr = JSON.stringify(payload);
|
||||||
const payloadB64 = bytesToBase64Url(textToBytes(payloadStr));
|
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: {
|
async registerWithInvitation(input: {
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
privateKey: 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 }> }> {
|
async listCollections(): Promise<{ collections: Array<{ id: string; name: string }> }> {
|
||||||
return this.request("/v1/collections", { method: "GET" });
|
return this.request("/v1/collections", { method: "GET" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a new collection. Returns id and name. */
|
||||||
async createCollection(name: string): Promise<{ id: string; name: string }> {
|
async createCollection(name: string): Promise<{ id: string; name: string }> {
|
||||||
return this.request("/v1/collections", { method: "POST", body: { name } });
|
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 }> {
|
async updateCollection(collectionId: string, name: string): Promise<{ id: string; name: string }> {
|
||||||
return this.request(`/v1/collections/${collectionId}`, {
|
return this.request(`/v1/collections/${collectionId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -146,14 +195,20 @@ export class GeoApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete a collection and its features. Must own it. */
|
||||||
async deleteCollection(collectionId: string): Promise<void> {
|
async deleteCollection(collectionId: string): Promise<void> {
|
||||||
return this.request(`/v1/collections/${collectionId}`, { method: "DELETE" });
|
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[] }> {
|
async listFeatures(collectionId: string): Promise<{ features: unknown[] }> {
|
||||||
return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
|
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(
|
async createPointFeature(
|
||||||
collectionId: string,
|
collectionId: string,
|
||||||
lon: number,
|
lon: number,
|
||||||
@@ -169,6 +224,7 @@ export class GeoApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete a feature. Must own it. */
|
||||||
async deleteFeature(featureId: string): Promise<void> {
|
async deleteFeature(featureId: string): Promise<void> {
|
||||||
return this.request(`/v1/features/${featureId}`, { method: "DELETE" });
|
return this.request(`/v1/features/${featureId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user