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:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
const bin = Array.from(bytes)
|
||||
.map((b) => String.fromCharCode(b))
|
||||
.join("");
|
||||
|
||||
if (typeof btoa !== "undefined") {
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
// Bun/Node fallback
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const b64 = (globalThis as any).Buffer.from(bytes).toString("base64");
|
||||
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(input: string): Uint8Array {
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLen = (4 - (normalized.length % 4)) % 4;
|
||||
const b64 = normalized + "=".repeat(padLen);
|
||||
|
||||
if (typeof atob !== "undefined") {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
out[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Bun/Node fallback
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Uint8Array((globalThis as any).Buffer.from(b64, "base64"));
|
||||
}
|
||||
|
||||
export function textToBytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { GeoApiClient } from "./GeoApiClient";
|
||||
export { generateKeyPair, signMessage } from "./keys";
|
||||
export { clearKeys, loadKeys, saveKeys, DEFAULT_KEYS_STORAGE_KEY } from "./storage";
|
||||
export type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getPublicKeyAsync, signAsync } from "@noble/ed25519";
|
||||
import { base64UrlToBytes, bytesToBase64Url, textToBytes } from "./encoding";
|
||||
import type { StoredKeys } from "./types";
|
||||
|
||||
function randomPrivateKey(): Uint8Array {
|
||||
const out = new Uint8Array(32);
|
||||
crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function generateKeyPair(): Promise<StoredKeys> {
|
||||
const privateKey = randomPrivateKey();
|
||||
const publicKey = await getPublicKeyAsync(privateKey);
|
||||
return {
|
||||
publicKey: bytesToBase64Url(publicKey),
|
||||
privateKey: bytesToBase64Url(privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
export async function signMessage(privateKeyBase64: string, message: string): Promise<string> {
|
||||
const privateKey = base64UrlToBytes(privateKeyBase64);
|
||||
const signature = await signAsync(textToBytes(message), privateKey);
|
||||
return bytesToBase64Url(signature);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { StorageLike, StoredKeys } from "./types";
|
||||
|
||||
export const DEFAULT_KEYS_STORAGE_KEY = "geo_api_keys_v1";
|
||||
|
||||
export function saveKeys(storage: StorageLike, keys: StoredKeys, storageKey = DEFAULT_KEYS_STORAGE_KEY): void {
|
||||
storage.setItem(storageKey, JSON.stringify(keys));
|
||||
}
|
||||
|
||||
export function loadKeys(storage: StorageLike, storageKey = DEFAULT_KEYS_STORAGE_KEY): StoredKeys | null {
|
||||
const raw = storage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as StoredKeys;
|
||||
if (!parsed.publicKey || !parsed.privateKey) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function clearKeys(storage: StorageLike, storageKey = DEFAULT_KEYS_STORAGE_KEY): void {
|
||||
storage.removeItem(storageKey);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type StoredKeys = {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
export type InvitationPayload = {
|
||||
jti: string;
|
||||
inviterPublicKey: string;
|
||||
inviteePublicKey?: string;
|
||||
expiresAtUnix: number;
|
||||
maxUses: number;
|
||||
};
|
||||
|
||||
export type StorageLike = {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
Reference in New Issue
Block a user