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:
@@ -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[];
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user