Implement geo backend, TS client, frontend, and CI tests.

Add a Go HTTP API with Ed25519 auth and invitation onboarding, user-scoped GeoJSON Point management, a Bun-tested @noble/ed25519 TypeScript client, static Vue/Vuetify frontend integration, and a Gitea CI workflow running both Go and Bun test suites.

Made-with: Cursor
This commit is contained in:
2026-03-01 11:41:21 +00:00
parent 5c73295ce5
commit 6e2becb06a
164 changed files with 446560 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
import { generateKeyPair, signMessage } from "./keys";
import { bytesToBase64Url, textToBytes } from "./encoding";
import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage";
import type { InvitationPayload, StorageLike, StoredKeys } from "./types";
type RequestInitLike = Omit<RequestInit, "body"> & { body?: unknown };
export class GeoApiClient {
private readonly baseUrl: string;
private readonly storage: StorageLike;
private readonly storageKey: string;
private accessToken: string | null = null;
constructor(baseUrl: string, storage: StorageLike, storageKey = DEFAULT_KEYS_STORAGE_KEY) {
this.baseUrl = baseUrl.replace(/\/+$/g, "");
this.storage = storage;
this.storageKey = storageKey;
}
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;
}
getStoredKeys(): StoredKeys | null {
return loadKeys(this.storage, this.storageKey);
}
importKeys(keys: StoredKeys): void {
saveKeys(this.storage, keys, this.storageKey);
}
exportKeys(): StoredKeys | null {
return loadKeys(this.storage, this.storageKey);
}
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(() => ({}));
const msg = (maybeJson as { error?: string }).error ?? `HTTP ${res.status}`;
throw new Error(msg);
}
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
}
async createChallenge(publicKey: string): Promise<{ nonce: string; messageToSign: string }> {
return this.request("/v1/auth/challenge", { method: "POST", body: { publicKey } });
}
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;
}
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,
},
});
}
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,
},
});
}
async listCollections(): Promise<{ collections: Array<{ id: string; name: string }> }> {
return this.request("/v1/collections", { method: "GET" });
}
async createCollection(name: string): Promise<{ id: string; name: string }> {
return this.request("/v1/collections", { method: "POST", body: { name } });
}
async listFeatures(collectionId: string): Promise<{ features: unknown[] }> {
return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
}
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,
},
});
}
}