Scaffold Regress: Fastify+SQLite server and Vue frontend for the Ingress-style BTC Map capture game

- Server: NIP-98 nostr auth (session cookies), BTC Map sync, claim/link/field
  routes, physical-presence checks via geolocation distance. 31 tests passing.
- Frontend: Leaflet map, team pick, claim/link UI, Archipelago identity
  bridge vendored (nostr-provider.js) for dashboard launch support.
- Verified end-to-end against the live BTC Map API (167 real Madeira places)
  and via genuine NIP-98-signed HTTP requests against the built app.
This commit is contained in:
2026-08-05 13:14:03 +00:00
parent cac528ba9a
commit d6ef514c84
47 changed files with 8444 additions and 2 deletions
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
import LoginView from './views/LoginView.vue';
import TeamPickView from './views/TeamPickView.vue';
import MapView from './views/MapView.vue';
const auth = useAuthStore();
onMounted(() => auth.ensureLoaded());
</script>
<template>
<div class="h-full w-full bg-neutral-950 text-white">
<div v-if="!auth.loaded" class="flex h-full items-center justify-center text-neutral-400">
Loading
</div>
<LoginView v-else-if="!auth.pubkey" />
<TeamPickView v-else-if="!auth.team" />
<MapView v-else />
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
// Thin fetch wrapper for the Regress API (session cookie auth).
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
const res = await fetch(path, {
method,
credentials: 'same-origin',
headers: {
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let message = res.statusText;
try {
message = ((await res.json()) as { error?: string }).error ?? message;
} catch {
/* keep statusText */
}
throw new ApiError(res.status, message);
}
return (await res.json()) as T;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
request<T>('POST', path, body, headers),
};
+21
View File
@@ -0,0 +1,21 @@
// Browser geolocation — required for claiming/linking (physical presence, Ingress-style).
export interface Position {
lat: number;
lon: number;
accuracy: number;
}
export function getCurrentPosition(): Promise<Position> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not available in this browser'));
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude, accuracy: pos.coords.accuracy }),
(err) => reject(new Error(`Could not get your location: ${err.message}`)),
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 },
);
});
}
+50
View File
@@ -0,0 +1,50 @@
// NIP-07 browser extension bridge + NIP-98 header construction.
// Identical contract to podsteadr's — also satisfied by nostr-provider.js's
// window.nostr shim when running inside the Archipelago dashboard.
export interface UnsignedEvent {
kind: number;
created_at: number;
content: string;
tags: string[][];
}
export interface SignedEvent extends UnsignedEvent {
id: string;
pubkey: string;
sig: string;
}
interface Nip07Provider {
getPublicKey(): Promise<string>;
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
}
declare global {
interface Window {
nostr?: Nip07Provider;
}
}
export function hasNip07(): boolean {
return typeof window !== 'undefined' && !!window.nostr;
}
export function nip07(): Nip07Provider {
if (!window.nostr) throw new Error('No NIP-07 nostr extension found');
return window.nostr;
}
/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */
export async function buildNip98Header(url: string, method: string): Promise<string> {
const event = await nip07().signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', url],
['method', method],
],
});
return `Nostr ${btoa(JSON.stringify(event))}`;
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import 'leaflet/dist/leaflet.css';
import App from './App.vue';
import './style.css';
createApp(App).use(createPinia()).mount('#app');
+57
View File
@@ -0,0 +1,57 @@
import { defineStore } from 'pinia';
import { api, ApiError } from '../lib/api';
import { buildNip98Header, hasNip07 } from '../lib/nip07';
export type Team = 'orange' | 'green';
interface Me {
pubkey: string;
displayName: string | null;
team: Team | null;
}
export const useAuthStore = defineStore('auth', {
state: () => ({
pubkey: null as string | null,
displayName: null as string | null,
team: null as Team | null,
loaded: false,
}),
actions: {
async ensureLoaded() {
if (this.loaded) return;
try {
const me = await api.get<Me>('/api/auth/me');
this.pubkey = me.pubkey;
this.displayName = me.displayName;
this.team = me.team;
} catch (err) {
if (!(err instanceof ApiError && err.status === 401)) throw err;
} finally {
this.loaded = true;
}
},
async login() {
if (!hasNip07()) throw new Error('No nostr extension found — install Alby or nos2x first.');
const url = `${location.origin}/api/auth/login`;
const header = await buildNip98Header(url, 'POST');
const res = await api.post<{ pubkey: string; team: Team | null }>(
'/api/auth/login',
undefined,
{ authorization: header },
);
this.pubkey = res.pubkey;
this.team = res.team;
},
async logout() {
await api.post('/api/auth/logout');
this.pubkey = null;
this.displayName = null;
this.team = null;
},
async chooseTeam(team: Team) {
const res = await api.post<{ team: Team }>('/api/auth/team', { team });
this.team = res.team;
},
},
});
+89
View File
@@ -0,0 +1,89 @@
import { defineStore } from 'pinia';
import { api } from '../lib/api';
import { getCurrentPosition } from '../lib/geo';
import type { Team } from './auth';
export interface Place {
id: number;
lat: number;
lon: number;
name: string;
icon: string | null;
address: string | null;
osm_id: string | null;
claim_team: Team | null;
claimed_by_pubkey: string | null;
claimed_at: number | null;
}
export interface LinkRow {
id: number;
from_place_id: number;
to_place_id: number;
team: Team;
}
export interface Field {
team: Team;
placeIds: [number, number, number];
areaKm2: number;
}
export interface TeamScore {
team: Team;
claimedPlaces: number;
links: number;
fields: number;
areaKm2: number;
}
export const useGameStore = defineStore('game', {
state: () => ({
places: [] as Place[],
links: [] as LinkRow[],
fields: [] as Field[],
scores: [] as TeamScore[],
loading: false,
error: null as string | null,
}),
actions: {
async refreshAll() {
this.loading = true;
this.error = null;
try {
const [places, links, fields, scores] = await Promise.all([
api.get<Place[]>('/api/places'),
api.get<LinkRow[]>('/api/links'),
api.get<Field[]>('/api/fields'),
api.get<TeamScore[]>('/api/score'),
]);
this.places = places;
this.links = links;
this.fields = fields;
this.scores = scores;
} catch (err) {
this.error = err instanceof Error ? err.message : String(err);
} finally {
this.loading = false;
}
},
/** Sync places from BTC Map — one-time/periodic bootstrap of the local place cache. */
async syncPlaces() {
await api.post('/api/sync');
await this.refreshAll();
},
async claim(placeId: number) {
const pos = await getCurrentPosition();
await api.post(`/api/places/${placeId}/claim`, { lat: pos.lat, lon: pos.lon });
await this.refreshAll();
},
async link(fromPlaceId: number, toPlaceId: number) {
const pos = await getCurrentPosition();
await api.post('/api/links', { fromPlaceId, toPlaceId, lat: pos.lat, lon: pos.lon });
await this.refreshAll();
},
},
});
+8
View File
@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #app {
height: 100%;
margin: 0;
}
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useAuthStore } from '../stores/auth';
import { hasNip07 } from '../lib/nip07';
const auth = useAuthStore();
const error = ref<string | null>(null);
const loggingIn = ref(false);
async function handleLogin() {
error.value = null;
loggingIn.value = true;
try {
await auth.login();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loggingIn.value = false;
}
}
</script>
<template>
<div class="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<h1 class="text-3xl font-bold">Regress</h1>
<p class="max-w-sm text-neutral-400">
Claim real Bitcoin-accepting businesses for your team. Log in with Nostr to start.
</p>
<button
class="rounded-lg bg-white px-6 py-3 font-semibold text-black transition hover:bg-neutral-200 disabled:opacity-50"
:disabled="loggingIn"
@click="handleLogin"
>
{{ loggingIn ? 'Signing in…' : 'Log in with Nostr' }}
</button>
<p v-if="!hasNip07()" class="text-sm text-neutral-500">
No NIP-07 extension detected install Alby or nos2x, or open this app from the Archipelago dashboard.
</p>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
</div>
</template>
+258
View File
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import L from 'leaflet';
import { useAuthStore } from '../stores/auth';
import { useGameStore } from '../stores/game';
import type { Place } from '../stores/game';
const auth = useAuthStore();
const game = useGameStore();
const mapEl = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let markersLayer: L.LayerGroup | null = null;
let linksLayer: L.LayerGroup | null = null;
let fieldsLayer: L.LayerGroup | null = null;
const selectedPlaceId = ref<number | null>(null);
const linkSourceId = ref<number | null>(null);
const actionError = ref<string | null>(null);
const actionBusy = ref(false);
const selectedPlace = computed<Place | null>(
() => game.places.find((p) => p.id === selectedPlaceId.value) ?? null,
);
const linkSourcePlace = computed<Place | null>(
() => game.places.find((p) => p.id === linkSourceId.value) ?? null,
);
const TEAM_COLOR: Record<string, string> = { orange: '#f97316', green: '#22c55e' };
const NEUTRAL_COLOR = '#9ca3af';
function colorFor(team: string | null): string {
return team ? TEAM_COLOR[team] : NEUTRAL_COLOR;
}
function redraw() {
if (!map || !markersLayer || !linksLayer || !fieldsLayer) return;
markersLayer.clearLayers();
linksLayer.clearLayers();
fieldsLayer.clearLayers();
const coordsById = new Map(game.places.map((p) => [p.id, p]));
for (const place of game.places) {
const isSource = place.id === linkSourceId.value;
const marker = L.circleMarker([place.lat, place.lon], {
radius: isSource ? 10 : 7,
color: isSource ? '#ffffff' : colorFor(place.claim_team),
weight: isSource ? 3 : 2,
fillColor: colorFor(place.claim_team),
fillOpacity: 0.85,
});
marker.on('click', () => onMarkerClick(place));
marker.bindTooltip(place.name || `Place ${place.id}`, { direction: 'top' });
markersLayer.addLayer(marker);
}
for (const link of game.links) {
const from = coordsById.get(link.from_place_id);
const to = coordsById.get(link.to_place_id);
if (!from || !to) continue;
linksLayer.addLayer(
L.polyline(
[
[from.lat, from.lon],
[to.lat, to.lon],
],
{ color: TEAM_COLOR[link.team], weight: 2, opacity: 0.8 },
),
);
}
for (const field of game.fields) {
const points = field.placeIds
.map((id) => coordsById.get(id))
.filter((p): p is Place => !!p)
.map((p) => [p.lat, p.lon] as [number, number]);
if (points.length !== 3) continue;
fieldsLayer.addLayer(
L.polygon(points, { color: TEAM_COLOR[field.team], weight: 1, fillOpacity: 0.15, stroke: false }),
);
}
}
function onMarkerClick(place: Place) {
selectedPlaceId.value = place.id;
actionError.value = null;
}
async function doClaim(place: Place) {
actionError.value = null;
actionBusy.value = true;
try {
await game.claim(place.id);
} catch (err) {
actionError.value = err instanceof Error ? err.message : String(err);
} finally {
actionBusy.value = false;
}
}
function startLink(place: Place) {
linkSourceId.value = place.id;
actionError.value = null;
}
function cancelLink() {
linkSourceId.value = null;
}
async function completeLink(place: Place) {
if (!linkSourceId.value) return;
actionError.value = null;
actionBusy.value = true;
try {
await game.link(linkSourceId.value, place.id);
linkSourceId.value = null;
} catch (err) {
actionError.value = err instanceof Error ? err.message : String(err);
} finally {
actionBusy.value = false;
}
}
async function doSync() {
actionError.value = null;
actionBusy.value = true;
try {
await game.syncPlaces();
} catch (err) {
actionError.value = err instanceof Error ? err.message : String(err);
} finally {
actionBusy.value = false;
}
}
const orangeScore = computed(() => game.scores.find((s) => s.team === 'orange'));
const greenScore = computed(() => game.scores.find((s) => s.team === 'green'));
onMounted(async () => {
if (mapEl.value) {
map = L.map(mapEl.value).setView([32.7607, -16.9595], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(map);
fieldsLayer = L.layerGroup().addTo(map);
linksLayer = L.layerGroup().addTo(map);
markersLayer = L.layerGroup().addTo(map);
}
await game.refreshAll();
redraw();
});
watch(() => [game.places, game.links, game.fields], redraw, { deep: true });
watch(linkSourceId, redraw);
</script>
<template>
<div class="flex h-full flex-col">
<header class="flex items-center justify-between gap-4 bg-neutral-900 px-4 py-2 text-sm">
<div class="flex items-center gap-4">
<span class="font-bold">Regress</span>
<span class="flex items-center gap-1 text-orange-500">
{{ orangeScore?.claimedPlaces ?? 0 }} places · {{ orangeScore?.fields ?? 0 }} fields ·
{{ (orangeScore?.areaKm2 ?? 0).toFixed(2) }} km²
</span>
<span class="flex items-center gap-1 text-green-500">
{{ greenScore?.claimedPlaces ?? 0 }} places · {{ greenScore?.fields ?? 0 }} fields ·
{{ (greenScore?.areaKm2 ?? 0).toFixed(2) }} km²
</span>
</div>
<div class="flex items-center gap-3">
<span class="text-neutral-400">
{{ auth.displayName ?? auth.pubkey?.slice(0, 8) }} ·
<span :class="auth.team === 'orange' ? 'text-orange-500' : 'text-green-500'">{{ auth.team }}</span>
</span>
<button class="rounded bg-neutral-800 px-3 py-1 hover:bg-neutral-700" :disabled="actionBusy" @click="doSync">
Sync from BTC Map
</button>
<button class="rounded bg-neutral-800 px-3 py-1 hover:bg-neutral-700" @click="auth.logout()">
Log out
</button>
</div>
</header>
<div class="relative flex-1">
<div ref="mapEl" class="h-full w-full"></div>
<div
v-if="linkSourcePlace"
class="absolute left-4 top-4 z-[1000] rounded-lg bg-neutral-900/95 px-4 py-2 text-sm shadow-lg"
>
Linking from <strong>{{ linkSourcePlace.name }}</strong> click a friendly place to complete, or
<button class="ml-2 text-neutral-400 underline" @click="cancelLink">cancel</button>
</div>
<div
v-if="selectedPlace"
class="absolute right-4 top-4 z-[1000] w-72 rounded-lg bg-neutral-900/95 p-4 text-sm shadow-lg"
>
<div class="mb-1 flex items-start justify-between gap-2">
<h2 class="font-bold">{{ selectedPlace.name || `Place ${selectedPlace.id}` }}</h2>
<button class="text-neutral-500" @click="selectedPlaceId = null"></button>
</div>
<p v-if="selectedPlace.address" class="mb-2 text-neutral-400">{{ selectedPlace.address }}</p>
<p class="mb-3">
Status:
<span v-if="!selectedPlace.claim_team" class="text-neutral-400">unclaimed</span>
<span v-else :class="selectedPlace.claim_team === 'orange' ? 'text-orange-500' : 'text-green-500'">
claimed by {{ selectedPlace.claim_team }}
</span>
</p>
<div class="flex flex-col gap-2">
<button
v-if="selectedPlace.claim_team !== auth.team"
class="rounded bg-white px-3 py-2 font-semibold text-black hover:bg-neutral-200 disabled:opacity-50"
:disabled="actionBusy"
@click="doClaim(selectedPlace)"
>
Claim for {{ auth.team }}
</button>
<button
v-else-if="linkSourceId !== selectedPlace.id"
class="rounded bg-neutral-800 px-3 py-2 hover:bg-neutral-700 disabled:opacity-50"
:disabled="actionBusy"
@click="startLink(selectedPlace)"
>
Link from here
</button>
<button
v-if="linkSourceId && linkSourceId !== selectedPlace.id && selectedPlace.claim_team === auth.team"
class="rounded bg-neutral-800 px-3 py-2 hover:bg-neutral-700 disabled:opacity-50"
:disabled="actionBusy"
@click="completeLink(selectedPlace)"
>
Complete link to here
</button>
</div>
<p v-if="actionError" class="mt-3 text-red-400">{{ actionError }}</p>
</div>
<div
v-if="!game.places.length && !game.loading"
class="absolute inset-0 z-[999] flex items-center justify-center bg-black/60"
>
<div class="rounded-lg bg-neutral-900 p-6 text-center">
<p class="mb-3">No places loaded yet pull the current Madeira dataset from BTC Map.</p>
<button class="rounded bg-white px-4 py-2 font-semibold text-black" :disabled="actionBusy" @click="doSync">
Sync from BTC Map
</button>
</div>
</div>
</div>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useAuthStore } from '../stores/auth';
import type { Team } from '../stores/auth';
const auth = useAuthStore();
const error = ref<string | null>(null);
const picking = ref(false);
async function pick(team: Team) {
error.value = null;
picking.value = true;
try {
await auth.chooseTeam(team);
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
picking.value = false;
}
}
</script>
<template>
<div class="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
<h1 class="text-2xl font-bold">Pick your faction</h1>
<p class="max-w-sm text-neutral-400">
This choice is permanent you can't switch teams later, so pick the side you're actually playing for.
</p>
<div class="flex gap-4">
<button
class="rounded-lg bg-orange-500 px-8 py-4 text-lg font-bold text-black transition hover:bg-orange-400 disabled:opacity-50"
:disabled="picking"
@click="pick('orange')"
>
Orange
</button>
<button
class="rounded-lg bg-green-500 px-8 py-4 text-lg font-bold text-black transition hover:bg-green-400 disabled:opacity-50"
:disabled="picking"
@click="pick('green')"
>
Green
</button>
</div>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
</div>
</template>