Files
backend/docs/typescript-frontend-integration.md
Andriy Oblivantsev 96b5e8f40f
CI / test (push) Successful in 3s
Improve TypeScript integration doc with concrete 3D upload flow.
This updates the example to compute SHA-256 from a selected GLB/GLTF file, create/link asset metadata, upload with signed URL, and use share links plus visibility toggling.

Made-with: Cursor
2026-03-02 21:32:21 +00:00

7.5 KiB

TypeScript Frontend Integration Guide

This document explains how frontend developers should integrate with the backend through the reusable TypeScript client at libs/geo-api-client.

See also: Frontend Development — demo app (web/), local dev, build steps.

Asset docs: Assets Storage and Sharing and Docker MinIO Local Development.

Primary backend URL for integration:

  • https://tenerife.baby/

Deployment: API is proxied via reverse proxy from https://tenerife.baby to backend at 172.17.0.1:8122. Docker Compose maps port 8122 for the reverse proxy.

Goals

  • Keep cryptographic signing logic in one place.
  • Avoid duplicating API request code in frontend apps.
  • Use a consistent local key storage format across projects.

Client package location

  • Source: libs/geo-api-client/src
  • Entry point: libs/geo-api-client/src/index.ts
  • Build output (browser ESM): libs/geo-api-client/dist/index.js

Build and test the client

cd libs/geo-api-client
bun install
bun test          # unit + integration tests (docs flow)
bun run build

Integration tests in test/integration.test.ts cover both:

  • Base flow: register, login, collection and feature CRUD
  • Asset flow: create/link asset, request signed upload URL, and toggle visibility

Public API (current)

Class: GeoApiClient

Source: libs/geo-api-client/src/GeoApiClient.ts

Constructor:

  • new GeoApiClient(baseUrl, storage, storageKey?)

Key methods:

  • Key storage

  • ensureKeysInStorage()

  • getStoredKeys()

  • derivePublicKey(privateKey)

  • importKeys(keys)

  • exportKeys()

  • setAccessToken(token)

  • Auth

  • getServicePublicKey()

  • createChallenge(publicKey)

  • loginWithSignature(publicKey, privateKey)

  • registerBySigningServiceKey(publicKey, privateKey)

  • createInvitation(payload, inviterPrivateKey)

  • registerWithInvitation(...)

  • Collections and features

  • listCollections()

  • createCollection(name)

  • updateCollection(collectionId, name)

  • deleteCollection(collectionId)

  • listFeatures(collectionId) (typed with properties.assets)

  • createPointFeature(collectionId, lon, lat, properties)

  • deleteFeature(featureId)

  • Assets (new)

  • createOrLinkAsset({...}) — create metadata or reuse existing asset by checksum/ext and link it to a feature

  • getAssetSignedUploadUrl(assetId, contentType?) — get signed PUT URL for binary upload

  • setAssetVisibility(assetId, isPublic) — owner toggles public/private access

  • resolveRelativeLink(path) — converts backend-relative asset links to absolute URLs for browser usage

Asset frontend contract

  • Feature responses include linked assets in feature.properties.assets.
  • Asset item fields include: id, kind, name, description, checksum, ext, isPublic, link.
  • link is always backend-relative (for example /v1/assets/{id}/download).
  • Frontend should call resolveRelativeLink(link) and must not build direct S3 URLs.
  1. Create one GeoApiClient instance per backend base URL.
  2. Call ensureKeysInStorage() when app initializes.
  3. If not yet registered: call registerBySigningServiceKey(publicKey, privateKey) (signs the API service key and publishes your public key).
  4. Use loginWithSignature() to obtain and set a bearer token.
  5. Create collection and map features.
  6. For media upload:
    • compute file checksum
    • call createOrLinkAsset
    • call getAssetSignedUploadUrl
    • upload file to signed URL
  7. Render and share assets from properties.assets links.
  8. Use setAssetVisibility to toggle sharing.
  9. Use importKeys/exportKeys in profile settings UX.

Registration by signing service key

When SERVICE_PUBLIC_KEY (or ADMIN_PUBLIC_KEY) is set, users can register without an invitation:

  1. GET /v1/service-key — fetch the server public key (clients use this for registration and further signed communication).
  2. Sign that key with your private key.
  3. POST /v1/auth/register-by-signature with { publicKey, signature }.

Server keys are generated with ./bin/gen-server-keys.sh and stored in etc/.

Example: place and upload a 3D object (.glb)

import { GeoApiClient } from "../libs/geo-api-client/dist/index.js";

const storage = window.localStorage;
const storageLike = {
  getItem: (key: string) => storage.getItem(key),
  setItem: (key: string, value: string) => storage.setItem(key, value),
  removeItem: (key: string) => storage.removeItem(key),
};

const client = new GeoApiClient("https://tenerife.baby", storageLike);
const keys = await client.ensureKeysInStorage();

// Register (ignored if already registered); then login
try {
  await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
} catch (_) {
  // Already registered or registration disabled
}
await client.loginWithSignature(keys.publicKey, keys.privateKey);

const created = await client.createCollection("My Places");
const feature = await client.createPointFeature(created.id, -16.6291, 28.4636, { name: "Santa Cruz" });

// Assume this comes from: <input id="modelFile" type="file" accept=".glb,.gltf" />
const fileInput = document.getElementById("modelFile") as HTMLInputElement;
const file = fileInput.files?.[0];
if (!file) throw new Error("Select a .glb/.gltf file first");

const toSha256Hex = async (f: File): Promise<string> => {
  const buffer = await f.arrayBuffer();
  const digest = await crypto.subtle.digest("SHA-256", buffer);
  return Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
};

const checksum = await toSha256Hex(file);
const ext = file.name.toLowerCase().endsWith(".gltf") ? "gltf" : "glb";

// Create metadata (or reuse existing by checksum+ext) and link to feature
const asset = await client.createOrLinkAsset({
  featureId: feature.id,
  checksum,
  ext,
  kind: "3d",
  mimeType: file.type || (ext === "gltf" ? "model/gltf+json" : "model/gltf-binary"),
  sizeBytes: file.size,
  name: "Palm Tree Model",
  description: "3D object placed on map",
  isPublic: true,
});

// Upload binary to object storage through signed URL
const signed = await client.getAssetSignedUploadUrl(asset.asset.id, asset.asset.mimeType);
await fetch(signed.url, {
  method: signed.method,
  headers: asset.asset.mimeType ? { "Content-Type": asset.asset.mimeType } : undefined,
  body: file,
});

// Read shareable relative link from feature payload
const features = await client.listFeatures(created.id);
const firstAsset = features.features[0]?.properties?.assets?.[0];
if (firstAsset) {
  const shareUrl = client.resolveRelativeLink(firstAsset.link);
  console.log("Share URL:", shareUrl);
}

// Optional: owner can disable public access later
await client.setAssetVisibility(asset.asset.id, false);

Security notes

  • Private keys are currently stored in browser storage via the selected storage adapter.
  • If your frontend has stronger security requirements, wrap the storage adapter with your own encryption/decryption layer before calling setItem/getItem.
  • Never send private keys to the backend.

No-build frontend compatibility

For no-bundler apps, import the built ESM file:

<script type="module">
  import { GeoApiClient } from "../libs/geo-api-client/dist/index.js";
  // use client...
</script>

The backend itself serves static UI at /web/, but this library can be consumed by any frontend runtime that supports fetch, TextEncoder, and ES modules.

For local development you can switch the client base URL to http://localhost:8122.