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
+28
View File
@@ -0,0 +1,28 @@
export interface BtcMapPlace {
id: number;
lat: number;
lon: number;
name?: string;
icon?: string;
address?: string;
osm_id?: string;
updated_at?: string;
}
const BTCMAP_FIELDS = 'id,lat,lon,name,icon,address,osm_id,updated_at';
/**
* Pull every BTC Map place within a radius of a center point. Used for the
* initial/periodic sync into our local `places` cache — see routes/sync.ts.
*/
export async function fetchAreaPlaces(lat: number, lon: number, radiusKm: number): Promise<BtcMapPlace[]> {
const url = new URL('https://api.btcmap.org/v4/places/search/');
url.searchParams.set('lat', String(lat));
url.searchParams.set('lon', String(lon));
url.searchParams.set('radius_km', String(radiusKm));
url.searchParams.set('fields', BTCMAP_FIELDS);
const res = await fetch(url);
if (!res.ok) throw new Error(`btcmap search failed: ${res.status} ${res.statusText}`);
return (await res.json()) as BtcMapPlace[];
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { computeFields, summarizeScores } from './fields.js';
const coords = new Map([
[1, { lat: 32.65, lon: -16.90 }],
[2, { lat: 32.66, lon: -16.91 }],
[3, { lat: 32.64, lon: -16.92 }],
[4, { lat: 32.70, lon: -16.95 }],
]);
describe('computeFields', () => {
it('finds no fields with fewer than 3 mutually-linked places', () => {
const links = [
{ fromPlaceId: 1, toPlaceId: 2, team: 'orange' },
{ fromPlaceId: 2, toPlaceId: 3, team: 'orange' },
];
expect(computeFields(links, coords)).toHaveLength(0);
});
it('detects a field when three same-team places are mutually linked', () => {
const links = [
{ fromPlaceId: 1, toPlaceId: 2, team: 'orange' },
{ fromPlaceId: 2, toPlaceId: 3, team: 'orange' },
{ fromPlaceId: 3, toPlaceId: 1, team: 'orange' },
];
const fields = computeFields(links, coords);
expect(fields).toHaveLength(1);
expect(fields[0].team).toBe('orange');
expect(fields[0].placeIds.sort()).toEqual([1, 2, 3]);
expect(fields[0].areaKm2).toBeGreaterThan(0);
});
it('does not mix teams into the same triangle', () => {
const links = [
{ fromPlaceId: 1, toPlaceId: 2, team: 'orange' },
{ fromPlaceId: 2, toPlaceId: 3, team: 'green' },
{ fromPlaceId: 3, toPlaceId: 1, team: 'orange' },
];
expect(computeFields(links, coords)).toHaveLength(0);
});
it('ignores links to places with no known coordinates', () => {
const links = [
{ fromPlaceId: 1, toPlaceId: 2, team: 'orange' },
{ fromPlaceId: 2, toPlaceId: 99, team: 'orange' },
{ fromPlaceId: 99, toPlaceId: 1, team: 'orange' },
];
expect(computeFields(links, coords)).toHaveLength(0);
});
it('finds multiple independent fields', () => {
const links = [
{ fromPlaceId: 1, toPlaceId: 2, team: 'orange' },
{ fromPlaceId: 2, toPlaceId: 3, team: 'orange' },
{ fromPlaceId: 3, toPlaceId: 1, team: 'orange' },
{ fromPlaceId: 1, toPlaceId: 4, team: 'green' },
];
const fields = computeFields(links, coords);
expect(fields).toHaveLength(1); // the green side has no triangle yet
});
});
describe('summarizeScores', () => {
it('aggregates claims, links, and field area per team', () => {
const fields = [{ team: 'orange', placeIds: [1, 2, 3] as [number, number, number], areaKm2: 2.5 }];
const scores = summarizeScores(
['orange', 'green'],
new Map([['orange', 5], ['green', 3]]),
new Map([['orange', 4], ['green', 1]]),
fields,
);
expect(scores).toEqual([
{ team: 'orange', claimedPlaces: 5, links: 4, fields: 1, areaKm2: 2.5 },
{ team: 'green', claimedPlaces: 3, links: 1, fields: 0, areaKm2: 0 },
]);
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { LatLon } from './geo.js';
import { triangleAreaKm2 } from './geo.js';
export interface LinkEdge {
fromPlaceId: number;
toPlaceId: number;
team: string;
}
export interface Field {
team: string;
placeIds: [number, number, number];
areaKm2: number;
}
/**
* Every set of three mutually-linked, same-team places forms a control field —
* mirrors Ingress's triangle-fill rule. Recomputed from scratch on read rather
* than persisted, since claims/links can invalidate fields from several call
* sites and a derived view can't go stale.
*/
export function computeFields(links: LinkEdge[], placeCoords: Map<number, LatLon>): Field[] {
const adjacencyByTeam = new Map<string, Map<number, Set<number>>>();
for (const link of links) {
if (!adjacencyByTeam.has(link.team)) adjacencyByTeam.set(link.team, new Map());
const adj = adjacencyByTeam.get(link.team)!;
if (!adj.has(link.fromPlaceId)) adj.set(link.fromPlaceId, new Set());
if (!adj.has(link.toPlaceId)) adj.set(link.toPlaceId, new Set());
adj.get(link.fromPlaceId)!.add(link.toPlaceId);
adj.get(link.toPlaceId)!.add(link.fromPlaceId);
}
const fields: Field[] = [];
for (const [team, adj] of adjacencyByTeam) {
const nodes = [...adj.keys()].sort((x, y) => x - y);
for (const u of nodes) {
const uNeighbors = [...adj.get(u)!].filter((v) => v > u);
for (const v of uNeighbors) {
const vNeighbors = adj.get(v)!;
for (const w of uNeighbors) {
if (w <= v || !vNeighbors.has(w)) continue;
const a = placeCoords.get(u);
const b = placeCoords.get(v);
const c = placeCoords.get(w);
if (!a || !b || !c) continue;
fields.push({ team, placeIds: [u, v, w], areaKm2: triangleAreaKm2(a, b, c) });
}
}
}
}
return fields;
}
export interface TeamScore {
team: string;
claimedPlaces: number;
links: number;
fields: number;
areaKm2: number;
}
export function summarizeScores(
teams: string[],
claimCounts: Map<string, number>,
linkCounts: Map<string, number>,
fields: Field[],
): TeamScore[] {
return teams.map((team) => {
const teamFields = fields.filter((f) => f.team === team);
return {
team,
claimedPlaces: claimCounts.get(team) ?? 0,
links: linkCounts.get(team) ?? 0,
fields: teamFields.length,
areaKm2: teamFields.reduce((sum, f) => sum + f.areaKm2, 0),
};
});
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { haversineMeters, triangleAreaKm2 } from './geo.js';
describe('haversineMeters', () => {
it('returns ~0 for the same point', () => {
expect(haversineMeters({ lat: 32.65, lon: -16.9 }, { lat: 32.65, lon: -16.9 })).toBeCloseTo(0, 3);
});
it('matches a known distance (roughly 1 degree of latitude ~= 111km)', () => {
const d = haversineMeters({ lat: 0, lon: 0 }, { lat: 1, lon: 0 });
expect(d).toBeGreaterThan(110_000);
expect(d).toBeLessThan(112_000);
});
});
describe('triangleAreaKm2', () => {
it('returns ~0 for degenerate (collinear) points', () => {
const area = triangleAreaKm2({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }, { lat: 0, lon: 2 });
expect(area).toBeCloseTo(0, 6);
});
it('computes a plausible area for a small real-world triangle', () => {
// Three points a few km apart around Funchal, Madeira.
const a = { lat: 32.6489863, lon: -16.9101835 };
const b = { lat: 32.638444, lon: -16.9340603 };
const c = { lat: 32.8233359, lon: -16.9901709 };
const area = triangleAreaKm2(a, b, c);
expect(area).toBeGreaterThan(0);
expect(area).toBeLessThan(1000); // sanity bound, not a precise reference value
});
});
+34
View File
@@ -0,0 +1,34 @@
export interface LatLon {
lat: number;
lon: number;
}
const EARTH_RADIUS_KM = 6371;
/** Great-circle distance between two points, in meters. */
export function haversineMeters(a: LatLon, b: LatLon): number {
const R_M = EARTH_RADIUS_KM * 1000;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
const lat1 = (a.lat * Math.PI) / 180;
const lat2 = (b.lat * Math.PI) / 180;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R_M * Math.asin(Math.sqrt(h));
}
/**
* Approximate area (km^2) of a small triangle given by three lat/lon points.
* Projects onto a local equirectangular plane centered on `a` — fine for
* triangle sizes within a single island; not meant for continent-scale fields.
*/
export function triangleAreaKm2(a: LatLon, b: LatLon, c: LatLon): number {
const toXY = (p: LatLon) => {
const latRad = (a.lat * Math.PI) / 180;
const x = ((p.lon - a.lon) * Math.PI) / 180 * EARTH_RADIUS_KM * Math.cos(latRad);
const y = ((p.lat - a.lat) * Math.PI) / 180 * EARTH_RADIUS_KM;
return { x, y };
};
const pb = toXY(b);
const pc = toXY(c);
return Math.abs(pb.x * pc.y - pc.x * pb.y) / 2;
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { verifyNip98, NIP98_KIND, Nip98Error } from './nip98.js';
const sk = generateSecretKey();
const pk = getPublicKey(sk);
const URL_ = 'http://localhost:8095/api/auth/login';
function makeHeader(overrides: Partial<{ kind: number; created_at: number; url: string; method: string }> = {}) {
const event = finalizeEvent(
{
kind: overrides.kind ?? NIP98_KIND,
created_at: overrides.created_at ?? Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', overrides.url ?? URL_],
['method', overrides.method ?? 'POST'],
],
},
sk,
);
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
}
const baseOpts = { allowedUrls: [URL_], method: 'POST', maxSkewSecs: 60 };
describe('verifyNip98', () => {
it('accepts a valid header and returns the pubkey', () => {
expect(verifyNip98(makeHeader(), baseOpts)).toBe(pk);
});
it('rejects missing header', () => {
expect(() => verifyNip98(undefined, baseOpts)).toThrow(Nip98Error);
});
it('rejects wrong kind', () => {
expect(() => verifyNip98(makeHeader({ kind: 1 }), baseOpts)).toThrow(/kind/);
});
it('rejects clock skew beyond the window', () => {
const old = Math.floor(Date.now() / 1000) - 120;
expect(() => verifyNip98(makeHeader({ created_at: old }), baseOpts)).toThrow(/clock/);
});
it('rejects a mismatched URL', () => {
expect(() =>
verifyNip98(makeHeader({ url: 'http://evil.example/api/auth/login' }), baseOpts),
).toThrow(/u tag/);
});
it('accepts equivalent URLs with trailing slash differences', () => {
expect(verifyNip98(makeHeader({ url: URL_ + '/' }), baseOpts)).toBe(pk);
});
it('rejects a mismatched method', () => {
expect(() => verifyNip98(makeHeader({ method: 'GET' }), baseOpts)).toThrow(/method/);
});
it('rejects tampered events (bad signature)', () => {
const event = JSON.parse(
Buffer.from(makeHeader().slice(6), 'base64').toString('utf8'),
);
event.tags.push(['t', 'tampered']);
const header = `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
expect(() => verifyNip98(header, baseOpts)).toThrow(/signature|u tag|method/);
});
it('rejects replayed events', () => {
const header = makeHeader();
const seen = new Set<string>();
const opts = {
...baseOpts,
isReplay: (id: string) => {
if (seen.has(id)) return true;
seen.add(id);
return false;
},
};
expect(verifyNip98(header, opts)).toBe(pk);
expect(() => verifyNip98(header, opts)).toThrow(/already used/);
});
it('rejects payload hash mismatch', () => {
const now = Math.floor(Date.now() / 1000);
const event = finalizeEvent(
{
kind: NIP98_KIND,
created_at: now,
content: '',
tags: [['u', URL_], ['method', 'POST'], ['payload', 'deadbeef']],
},
sk,
);
const header = `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
expect(() => verifyNip98(header, { ...baseOpts, body: Buffer.from('{"a":1}') })).toThrow(/payload/);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { createHash } from 'node:crypto';
import { verifyEvent, type Event } from 'nostr-tools/pure';
export const NIP98_KIND = 27235;
export class Nip98Error extends Error {}
export interface Nip98Options {
/** Full URLs the signed `u` tag is allowed to match (same request via different hosts). */
allowedUrls: string[];
method: string;
body?: Buffer | null;
maxSkewSecs: number;
now?: number;
/** Returns true if the event id was already used (replay). */
isReplay?: (eventId: string) => boolean;
}
function tag(event: Event, name: string): string | undefined {
return event.tags.find((t) => t[0] === name)?.[1];
}
function normalizeUrl(u: string): string {
try {
const url = new URL(u);
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '') || '/'}${url.search}`;
} catch {
return u;
}
}
/** Verify a NIP-98 `Authorization: Nostr <base64 event>` header. Returns the signer pubkey. */
export function verifyNip98(header: string | undefined, opts: Nip98Options): string {
if (!header?.startsWith('Nostr ')) throw new Nip98Error('missing Nostr authorization header');
let event: Event;
try {
event = JSON.parse(Buffer.from(header.slice(6).trim(), 'base64').toString('utf8'));
} catch {
throw new Nip98Error('malformed authorization event');
}
if (event.kind !== NIP98_KIND) throw new Nip98Error(`wrong event kind (expected ${NIP98_KIND})`);
const now = opts.now ?? Math.floor(Date.now() / 1000);
if (Math.abs(now - event.created_at) > opts.maxSkewSecs) {
throw new Nip98Error('authorization event expired or clock skew too large — check your clock');
}
const u = tag(event, 'u');
if (!u || !opts.allowedUrls.some((a) => normalizeUrl(a) === normalizeUrl(u))) {
throw new Nip98Error('u tag does not match the request URL');
}
const method = tag(event, 'method');
if (!method || method.toUpperCase() !== opts.method.toUpperCase()) {
throw new Nip98Error('method tag does not match the request method');
}
if (opts.body && opts.body.length > 0) {
const payload = tag(event, 'payload');
const digest = createHash('sha256').update(opts.body).digest('hex');
if (payload && payload !== digest) throw new Nip98Error('payload hash mismatch');
}
if (!verifyEvent(event)) throw new Nip98Error('invalid event signature');
if (opts.isReplay?.(event.id)) throw new Nip98Error('authorization event already used');
return event.pubkey;
}