This adds typed asset APIs to the geo client, covers the 3D/image upload-share flow in integration tests, and introduces a simple Leaflet web demo that places objects on map features and manages sharing visibility via backend links. Made-with: Cursor
This commit is contained in:
Vendored
+20
@@ -638,6 +638,26 @@ class GeoApiClient {
|
||||
async deleteFeature(featureId) {
|
||||
return this.request(`/v1/features/${featureId}`, { method: "DELETE" });
|
||||
}
|
||||
async createOrLinkAsset(input) {
|
||||
return this.request("/v1/assets", { method: "POST", body: input });
|
||||
}
|
||||
async getAssetSignedUploadUrl(assetId, contentType) {
|
||||
return this.request(`/v1/assets/${assetId}/signed-upload`, {
|
||||
method: "POST",
|
||||
body: { contentType: contentType ?? "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
async setAssetVisibility(assetId, isPublic) {
|
||||
return this.request(`/v1/assets/${assetId}`, {
|
||||
method: "PATCH",
|
||||
body: { isPublic }
|
||||
});
|
||||
}
|
||||
resolveRelativeLink(path) {
|
||||
if (!path.startsWith("/"))
|
||||
return `${this.baseUrl}/${path}`;
|
||||
return `${this.baseUrl}${path}`;
|
||||
}
|
||||
}
|
||||
export {
|
||||
signMessage,
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys";
|
||||
import { bytesToBase64Url, textToBytes } from "./encoding";
|
||||
import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage";
|
||||
import type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
||||
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.
|
||||
@@ -201,7 +214,7 @@ export class GeoApiClient {
|
||||
}
|
||||
|
||||
/** List GeoJSON features in a collection. Must own the collection. */
|
||||
async listFeatures(collectionId: string): Promise<{ features: unknown[] }> {
|
||||
async listFeatures(collectionId: string): Promise<{ features: GeoFeature[] }> {
|
||||
return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
|
||||
}
|
||||
|
||||
@@ -228,4 +241,47 @@ export class GeoApiClient {
|
||||
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 signed upload URL for an existing asset. */
|
||||
async getAssetSignedUploadUrl(
|
||||
assetId: string,
|
||||
contentType?: string
|
||||
): Promise<{ url: string; method: string }> {
|
||||
return this.request(`/v1/assets/${assetId}/signed-upload`, {
|
||||
method: "POST",
|
||||
body: { contentType: contentType ?? "application/octet-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
/** 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export { GeoApiClient } from "./GeoApiClient";
|
||||
export { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys";
|
||||
export { clearKeys, loadKeys, saveKeys, DEFAULT_KEYS_STORAGE_KEY } from "./storage";
|
||||
export type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
||||
export type {
|
||||
AssetKind,
|
||||
AssetRecord,
|
||||
AssetVisibility,
|
||||
FeatureAsset,
|
||||
InvitationPayload,
|
||||
StorageLike,
|
||||
StoredKeys,
|
||||
} from "./types";
|
||||
|
||||
@@ -16,3 +16,34 @@ export type StorageLike = {
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
export type AssetKind = "image" | "3d";
|
||||
|
||||
export type AssetVisibility = {
|
||||
isPublic: boolean;
|
||||
};
|
||||
|
||||
export type AssetRecord = {
|
||||
id: string;
|
||||
ownerKey: string;
|
||||
checksum: string;
|
||||
ext: string;
|
||||
kind: AssetKind;
|
||||
mimeType?: string;
|
||||
sizeBytes: number;
|
||||
objectKey: string;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type FeatureAsset = {
|
||||
id: string;
|
||||
kind: AssetKind;
|
||||
name?: string;
|
||||
description?: string;
|
||||
checksum: string;
|
||||
ext: string;
|
||||
isPublic: boolean;
|
||||
link: string;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,22 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType<typ
|
||||
const sessions = new Map<string, string>();
|
||||
let collectionId = 0;
|
||||
let featureId = 0;
|
||||
let assetId = 0;
|
||||
const collectionsByUser = new Map<string, Array<{ id: string; name: string }>>();
|
||||
const featuresByCollection = new Map<string, Array<{ id: string; geometry: unknown; properties: Record<string, unknown> }>>();
|
||||
const assetsByOwner = new Map<string, Array<{
|
||||
id: string;
|
||||
ownerKey: string;
|
||||
checksum: string;
|
||||
ext: string;
|
||||
kind: "image" | "3d";
|
||||
mimeType?: string;
|
||||
sizeBytes: number;
|
||||
objectKey: string;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>>();
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
@@ -98,35 +114,138 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType<typ
|
||||
if (!user && path.startsWith("/v1/collections") && method !== "GET") {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user && path.startsWith("/v1/assets")) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// POST /v1/collections
|
||||
if (method === "POST" && path === "/v1/collections") {
|
||||
const body = (await req.json()) as { name?: string };
|
||||
collectionId++;
|
||||
const c = { id: `col-${collectionId}`, name: body?.name ?? "Unnamed" };
|
||||
const list = collectionsByUser.get(user!) ?? [];
|
||||
list.push(c);
|
||||
collectionsByUser.set(user!, list);
|
||||
return Response.json(c, { status: 201 });
|
||||
}
|
||||
|
||||
// GET /v1/collections
|
||||
if (method === "GET" && path === "/v1/collections") {
|
||||
return Response.json({ collections: [] });
|
||||
return Response.json({ collections: collectionsByUser.get(user ?? "") ?? [] });
|
||||
}
|
||||
|
||||
// POST /v1/collections/:id/features
|
||||
if (method === "POST" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) {
|
||||
const colId = path.split("/")[3]!;
|
||||
const owned = (collectionsByUser.get(user!) ?? []).some((c) => c.id === colId);
|
||||
if (!owned) {
|
||||
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = (await req.json()) as { geometry?: unknown; properties?: unknown };
|
||||
featureId++;
|
||||
const f = {
|
||||
id: `feat-${featureId}`,
|
||||
geometry: body?.geometry ?? { type: "Point", coordinates: [0, 0] },
|
||||
properties: body?.properties ?? {},
|
||||
properties: (body?.properties as Record<string, unknown>) ?? {},
|
||||
};
|
||||
const list = featuresByCollection.get(colId) ?? [];
|
||||
list.push(f);
|
||||
featuresByCollection.set(colId, list);
|
||||
return Response.json(f, { status: 201 });
|
||||
}
|
||||
|
||||
// GET /v1/collections/:id/features
|
||||
if (method === "GET" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) {
|
||||
return Response.json({ features: [] });
|
||||
const colId = path.split("/")[3]!;
|
||||
const features = featuresByCollection.get(colId) ?? [];
|
||||
const ownerAssets = assetsByOwner.get(user ?? "") ?? [];
|
||||
const withAssets = features.map((f) => {
|
||||
const assets = ownerAssets
|
||||
.filter((a) => (f.properties.assets as Array<{ id: string }> | undefined)?.some((x) => x.id === a.id))
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
kind: a.kind,
|
||||
checksum: a.checksum,
|
||||
ext: a.ext,
|
||||
isPublic: a.isPublic,
|
||||
link: `/v1/assets/${a.id}/download`,
|
||||
}));
|
||||
return { ...f, properties: { ...f.properties, assets } };
|
||||
});
|
||||
return Response.json({ features: withAssets });
|
||||
}
|
||||
|
||||
// POST /v1/assets
|
||||
if (method === "POST" && path === "/v1/assets") {
|
||||
const body = (await req.json()) as {
|
||||
featureId?: string;
|
||||
checksum?: string;
|
||||
ext?: string;
|
||||
kind?: "image" | "3d";
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
};
|
||||
if (!body.featureId || !body.checksum || !body.ext || !body.kind) {
|
||||
return Response.json({ error: "missing fields" }, { status: 400 });
|
||||
}
|
||||
const ownerAssets = assetsByOwner.get(user!) ?? [];
|
||||
const existing = ownerAssets.find(
|
||||
(a) => a.checksum === body.checksum.toLowerCase() && a.ext === body.ext.toLowerCase()
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const asset =
|
||||
existing ??
|
||||
{
|
||||
id: `asset-${++assetId}`,
|
||||
ownerKey: user!,
|
||||
checksum: body.checksum.toLowerCase(),
|
||||
ext: body.ext.toLowerCase(),
|
||||
kind: body.kind,
|
||||
mimeType: body.mimeType,
|
||||
sizeBytes: body.sizeBytes ?? 0,
|
||||
objectKey: `${user!}/${body.checksum.toLowerCase()}.${body.ext.toLowerCase()}`,
|
||||
isPublic: body.isPublic ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (!existing) {
|
||||
ownerAssets.push(asset);
|
||||
assetsByOwner.set(user!, ownerAssets);
|
||||
}
|
||||
for (const featureList of featuresByCollection.values()) {
|
||||
for (const f of featureList) {
|
||||
if (f.id === body.featureId) {
|
||||
const oldAssets = Array.isArray(f.properties.assets) ? (f.properties.assets as Array<{ id: string }>) : [];
|
||||
if (!oldAssets.some((x) => x.id === asset.id)) {
|
||||
f.properties.assets = [...oldAssets, { id: asset.id, name: body.name, description: body.description }];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Response.json({ asset, link: `/v1/assets/${asset.id}/download` }, { status: existing ? 200 : 201 });
|
||||
}
|
||||
|
||||
// POST /v1/assets/:id/signed-upload
|
||||
if (method === "POST" && path.match(/^\/v1\/assets\/[^/]+\/signed-upload$/)) {
|
||||
const id = path.split("/")[3]!;
|
||||
return Response.json({ url: `http://upload.local/${id}`, method: "PUT" });
|
||||
}
|
||||
|
||||
// PATCH /v1/assets/:id
|
||||
if (method === "PATCH" && path.match(/^\/v1\/assets\/[^/]+$/)) {
|
||||
const id = path.split("/")[3]!;
|
||||
const body = (await req.json()) as { isPublic?: boolean };
|
||||
const ownerAssets = assetsByOwner.get(user!) ?? [];
|
||||
const asset = ownerAssets.find((a) => a.id === id);
|
||||
if (!asset) {
|
||||
return Response.json({ error: "not found" }, { status: 404 });
|
||||
}
|
||||
asset.isPublic = Boolean(body.isPublic);
|
||||
asset.updatedAt = new Date().toISOString();
|
||||
return Response.json({ asset, link: `/v1/assets/${asset.id}/download` });
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
@@ -180,4 +299,41 @@ describe("GeoApiClient integration (docs flow)", () => {
|
||||
const { features } = await client.listFeatures(created.id);
|
||||
expect(Array.isArray(features)).toBe(true);
|
||||
});
|
||||
|
||||
test("asset flow: create/link -> signed upload -> toggle visibility", async () => {
|
||||
const keys = await client.ensureKeysInStorage();
|
||||
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
|
||||
await client.loginWithSignature(keys.publicKey, keys.privateKey);
|
||||
|
||||
const created = await client.createCollection("3D");
|
||||
const feature = await client.createPointFeature(created.id, -16.6291, 28.4636, { name: "Palm Tree Point" });
|
||||
|
||||
const createdAsset = await client.createOrLinkAsset({
|
||||
featureId: feature.id,
|
||||
checksum: "ABCDEF0123",
|
||||
ext: "glb",
|
||||
kind: "3d",
|
||||
mimeType: "model/gltf-binary",
|
||||
sizeBytes: 1024,
|
||||
name: "Palm Tree",
|
||||
description: "Low poly",
|
||||
isPublic: true,
|
||||
});
|
||||
expect(createdAsset.asset.id).toBeDefined();
|
||||
expect(createdAsset.link).toBe(`/v1/assets/${createdAsset.asset.id}/download`);
|
||||
expect(client.resolveRelativeLink(createdAsset.link)).toContain(`/v1/assets/${createdAsset.asset.id}/download`);
|
||||
|
||||
const upload = await client.getAssetSignedUploadUrl(createdAsset.asset.id, "model/gltf-binary");
|
||||
expect(upload.method).toBe("PUT");
|
||||
expect(upload.url).toContain(createdAsset.asset.id);
|
||||
|
||||
const toggled = await client.setAssetVisibility(createdAsset.asset.id, false);
|
||||
expect(toggled.asset.isPublic).toBe(false);
|
||||
|
||||
const listed = await client.listFeatures(created.id);
|
||||
const first = listed.features[0];
|
||||
const assets = first.properties?.assets ?? [];
|
||||
expect(assets.length).toBeGreaterThan(0);
|
||||
expect(assets[0]?.id).toBe(createdAsset.asset.id);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user