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
+70
View File
@@ -0,0 +1,70 @@
import type { FastifyInstance } from 'fastify';
import { Nip98Error } from '../services/nip98.js';
import type { Team, User } from '../types.js';
function nowSecs(): number {
return Math.floor(Date.now() / 1000);
}
export default async function authRoutes(app: FastifyInstance) {
const { db } = app.ctx;
const upsertUser = db.prepare(`
INSERT INTO users (pubkey, created_at, last_login_at) VALUES (?, ?, ?)
ON CONFLICT(pubkey) DO UPDATE SET last_login_at = excluded.last_login_at
`);
const selectUser = db.prepare('SELECT * FROM users WHERE pubkey = ?');
const updateProfile = db.prepare('UPDATE users SET display_name = ? WHERE pubkey = ?');
const setTeam = db.prepare('UPDATE users SET team = ? WHERE pubkey = ? AND team IS NULL');
app.post('/api/auth/login', async (req, reply) => {
let pubkey: string;
try {
pubkey = app.verifyNip98Request(req);
} catch (err) {
if (err instanceof Nip98Error) return reply.code(401).send({ error: err.message });
throw err;
}
upsertUser.run(pubkey, nowSecs(), nowSecs());
const body = req.body as { displayName?: string } | null;
if (body?.displayName) {
updateProfile.run(body.displayName, pubkey);
}
app.createSession(pubkey, reply);
const user = selectUser.get(pubkey) as User;
return { pubkey, team: user.team };
});
app.post('/api/auth/logout', async (req, reply) => {
app.destroySession(req, reply);
return { ok: true };
});
app.get('/api/auth/me', { preHandler: app.requireAuth }, async (req) => {
const user = selectUser.get(req.userPubkey) as User | undefined;
return {
pubkey: req.userPubkey,
displayName: user?.display_name ?? null,
team: user?.team ?? null,
};
});
// Faction choice is one-way once made — matches Ingress not letting you swap sides
// on a whim. If this ever needs to change, it should be an explicit admin action,
// not a self-service re-pick.
app.post('/api/auth/team', { preHandler: app.requireAuth }, async (req, reply) => {
const body = req.body as { team?: Team };
if (body?.team !== 'orange' && body?.team !== 'green') {
return reply.code(400).send({ error: 'team must be "orange" or "green"' });
}
const result = setTeam.run(body.team, req.userPubkey);
if (result.changes === 0) {
const user = selectUser.get(req.userPubkey) as User;
if (user.team) return reply.code(409).send({ error: `already on team ${user.team}` });
return reply.code(500).send({ error: 'failed to set team' });
}
return { pubkey: req.userPubkey, team: body.team };
});
}
+63
View File
@@ -0,0 +1,63 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { haversineMeters } from '../services/geo.js';
import type { Claim, Place, User } from '../types.js';
const claimBody = z.object({
lat: z.number(),
lon: z.number(),
});
function nowSecs(): number {
return Math.floor(Date.now() / 1000);
}
export default async function claimsRoutes(app: FastifyInstance) {
const { db, config } = app.ctx;
const getPlace = db.prepare('SELECT * FROM places WHERE id = ?');
const getUser = db.prepare('SELECT * FROM users WHERE pubkey = ?');
const getClaim = db.prepare('SELECT * FROM claims WHERE place_id = ?');
const deleteLinksTouching = db.prepare('DELETE FROM links WHERE from_place_id = ? OR to_place_id = ?');
const upsertClaim = db.prepare(`
INSERT INTO claims (place_id, team, claimed_by_pubkey, claimed_at) VALUES (?, ?, ?, ?)
ON CONFLICT(place_id) DO UPDATE SET
team = excluded.team,
claimed_by_pubkey = excluded.claimed_by_pubkey,
claimed_at = excluded.claimed_at
`);
app.post('/api/places/:id/claim', { preHandler: app.requireAuth }, async (req, reply) => {
const placeId = Number((req.params as { id: string }).id);
const parsed = claimBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'lat and lon are required' });
const user = getUser.get(req.userPubkey) as User | undefined;
if (!user?.team) return reply.code(400).send({ error: 'pick a team before claiming (POST /api/auth/team)' });
const place = getPlace.get(placeId) as Place | undefined;
if (!place) return reply.code(404).send({ error: 'place not found' });
const distance = haversineMeters(parsed.data, place);
if (distance > config.CLAIM_RADIUS_METERS) {
return reply.code(403).send({
error: `too far away: ${Math.round(distance)}m from this place (must be within ${config.CLAIM_RADIUS_METERS}m)`,
});
}
const existing = getClaim.get(placeId) as Claim | undefined;
if (existing?.team === user.team) {
return reply.code(409).send({ error: 'already claimed by your team' });
}
// Recapturing (or freshly capturing a neutral place) tears down any links
// through it — a place can't stay part of an enemy field once it flips,
// and a neutral place never had links in the first place so this is a no-op then.
db.transaction(() => {
deleteLinksTouching.run(placeId, placeId);
upsertClaim.run(placeId, user.team, req.userPubkey, nowSecs());
})();
return { placeId, team: user.team, claimedBy: req.userPubkey };
});
}
+34
View File
@@ -0,0 +1,34 @@
import type { FastifyInstance } from 'fastify';
import { computeFields, summarizeScores } from '../services/fields.js';
import type { LinkRow, Place } from '../types.js';
export default async function fieldsRoutes(app: FastifyInstance) {
const { db } = app.ctx;
const listLinks = db.prepare('SELECT * FROM links');
const listPlaces = db.prepare('SELECT id, lat, lon FROM places');
const countClaims = db.prepare('SELECT team, COUNT(*) AS n FROM claims GROUP BY team');
const countLinks = db.prepare('SELECT team, COUNT(*) AS n FROM links GROUP BY team');
function currentFields() {
const links = (listLinks.all() as LinkRow[]).map((l) => ({
fromPlaceId: l.from_place_id,
toPlaceId: l.to_place_id,
team: l.team,
}));
const places = listPlaces.all() as Pick<Place, 'id' | 'lat' | 'lon'>[];
const coords = new Map(places.map((p) => [p.id, { lat: p.lat, lon: p.lon }]));
return computeFields(links, coords);
}
app.get('/api/fields', async () => {
return currentFields();
});
app.get('/api/score', async () => {
const fields = currentFields();
const claimCounts = new Map((countClaims.all() as { team: string; n: number }[]).map((r) => [r.team, r.n]));
const linkCounts = new Map((countLinks.all() as { team: string; n: number }[]).map((r) => [r.team, r.n]));
return summarizeScores(['orange', 'green'], claimCounts, linkCounts, fields);
});
}
+72
View File
@@ -0,0 +1,72 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { haversineMeters } from '../services/geo.js';
import type { Claim, Place, User } from '../types.js';
const linkBody = z.object({
fromPlaceId: z.number().int(),
toPlaceId: z.number().int(),
lat: z.number(),
lon: z.number(),
});
function nowSecs(): number {
return Math.floor(Date.now() / 1000);
}
export default async function linksRoutes(app: FastifyInstance) {
const { db, config } = app.ctx;
const getPlace = db.prepare('SELECT * FROM places WHERE id = ?');
const getUser = db.prepare('SELECT * FROM users WHERE pubkey = ?');
const getClaim = db.prepare('SELECT * FROM claims WHERE place_id = ?');
const getExistingLink = db.prepare(`
SELECT * FROM links WHERE (from_place_id = ? AND to_place_id = ?) OR (from_place_id = ? AND to_place_id = ?)
`);
const insertLink = db.prepare(`
INSERT INTO links (from_place_id, to_place_id, team, created_by_pubkey, created_at)
VALUES (?, ?, ?, ?, ?)
`);
app.post('/api/links', { preHandler: app.requireAuth }, async (req, reply) => {
const parsed = linkBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'fromPlaceId, toPlaceId, lat, lon are required' });
const { fromPlaceId, toPlaceId, lat, lon } = parsed.data;
if (fromPlaceId === toPlaceId) return reply.code(400).send({ error: 'cannot link a place to itself' });
const user = getUser.get(req.userPubkey) as User | undefined;
if (!user?.team) return reply.code(400).send({ error: 'pick a team before linking (POST /api/auth/team)' });
const fromPlace = getPlace.get(fromPlaceId) as Place | undefined;
const toPlace = getPlace.get(toPlaceId) as Place | undefined;
if (!fromPlace || !toPlace) return reply.code(404).send({ error: 'place not found' });
const fromClaim = getClaim.get(fromPlaceId) as Claim | undefined;
const toClaim = getClaim.get(toPlaceId) as Claim | undefined;
if (fromClaim?.team !== user.team || toClaim?.team !== user.team) {
return reply.code(403).send({ error: 'both places must be claimed by your team' });
}
// You must be physically at the origin portal to link out from it — same
// "hack a portal to get a key" presence requirement as Ingress. No range
// limit on the link's total length (per game design decision).
const distance = haversineMeters({ lat, lon }, fromPlace);
if (distance > config.CLAIM_RADIUS_METERS) {
return reply.code(403).send({
error: `too far from the origin place: ${Math.round(distance)}m (must be within ${config.CLAIM_RADIUS_METERS}m)`,
});
}
if (getExistingLink.get(fromPlaceId, toPlaceId, toPlaceId, fromPlaceId)) {
return reply.code(409).send({ error: 'these places are already linked' });
}
const result = insertLink.run(fromPlaceId, toPlaceId, user.team, req.userPubkey, nowSecs());
return { id: result.lastInsertRowid, fromPlaceId, toPlaceId, team: user.team };
});
app.get('/api/links', async () => {
return db.prepare('SELECT * FROM links ORDER BY id').all();
});
}
+33
View File
@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
export default async function placesRoutes(app: FastifyInstance) {
const { db } = app.ctx;
const listPlaces = db.prepare(`
SELECT p.*, c.team AS claim_team, c.claimed_by_pubkey, c.claimed_at
FROM places p
LEFT JOIN claims c ON c.place_id = p.id
ORDER BY p.id
`);
const getPlace = db.prepare(`
SELECT p.*, c.team AS claim_team, c.claimed_by_pubkey, c.claimed_at
FROM places p
LEFT JOIN claims c ON c.place_id = p.id
WHERE p.id = ?
`);
const getLinksForPlace = db.prepare(`
SELECT * FROM links WHERE from_place_id = ? OR to_place_id = ?
`);
app.get('/api/places', async () => {
return listPlaces.all();
});
app.get('/api/places/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id);
const place = getPlace.get(id);
if (!place) return reply.code(404).send({ error: 'place not found' });
const links = getLinksForPlace.all(id, id);
return { ...place, links };
});
}
+46
View File
@@ -0,0 +1,46 @@
import type { FastifyInstance } from 'fastify';
import { fetchAreaPlaces } from '../services/btcmap.js';
export default async function syncRoutes(app: FastifyInstance) {
const { db, config } = app.ctx;
const upsertPlace = db.prepare(`
INSERT INTO places (id, lat, lon, name, icon, address, osm_id, btcmap_updated_at, synced_at)
VALUES (@id, @lat, @lon, @name, @icon, @address, @osm_id, @btcmap_updated_at, @synced_at)
ON CONFLICT(id) DO UPDATE SET
lat = excluded.lat,
lon = excluded.lon,
name = excluded.name,
icon = excluded.icon,
address = excluded.address,
osm_id = excluded.osm_id,
btcmap_updated_at = excluded.btcmap_updated_at,
synced_at = excluded.synced_at
`);
// Public on purpose for now (single-region MVP, no write access to real money);
// revisit if/when this needs to be admin-gated for a larger, costlier sync area.
app.post('/api/sync', async () => {
const places = await fetchAreaPlaces(config.BTCMAP_CENTER_LAT, config.BTCMAP_CENTER_LON, config.BTCMAP_RADIUS_KM);
const syncedAt = Math.floor(Date.now() / 1000);
const insertAll = db.transaction((rows: typeof places) => {
for (const p of rows) {
upsertPlace.run({
id: p.id,
lat: p.lat,
lon: p.lon,
name: p.name ?? '',
icon: p.icon ?? null,
address: p.address ?? null,
osm_id: p.osm_id ?? null,
btcmap_updated_at: p.updated_at ?? null,
synced_at: syncedAt,
});
}
});
insertAll(places);
return { synced: places.length, center: { lat: config.BTCMAP_CENTER_LAT, lon: config.BTCMAP_CENTER_LON }, radiusKm: config.BTCMAP_RADIUS_KM };
});
}