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:
Generated
+3335
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "regress-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"nostr-tools": "^2.15.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.0",
|
||||
"tsx": "^4.20.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vitest": "^3.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buildApp } from './app.js';
|
||||
import { loadConfig } from './config.js';
|
||||
|
||||
// Three real Madeira BTC Map places, close enough together to link/field in tests.
|
||||
const PLACE_A = { id: 1128, lat: 32.6489863, lon: -16.9101835, name: 'Maia' };
|
||||
const PLACE_B = { id: 16251, lat: 32.650618, lon: -16.9096355, name: 'Jacafé' };
|
||||
const PLACE_C = { id: 16366, lat: 32.6493039, lon: -16.9087116, name: 'Museu Café' };
|
||||
// Far enough away that it's outside the default 40m claim radius from any of the above.
|
||||
const FAR_LAT = 32.8233359;
|
||||
const FAR_LON = -16.9901709;
|
||||
|
||||
const skA = generateSecretKey();
|
||||
const pkA = getPublicKey(skA);
|
||||
const skB = generateSecretKey();
|
||||
const pkB = getPublicKey(skB);
|
||||
|
||||
let app: FastifyInstance;
|
||||
let dataDir: string;
|
||||
let cookieA: string;
|
||||
let cookieB: string;
|
||||
|
||||
function nip98Header(sk: Uint8Array, url: string, method: string): string {
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]],
|
||||
},
|
||||
sk,
|
||||
);
|
||||
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
|
||||
}
|
||||
|
||||
async function login(sk: Uint8Array): Promise<string> {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: { authorization: nip98Header(sk, 'http://localhost:8096/api/auth/login', 'POST') },
|
||||
});
|
||||
const setCookie = res.headers['set-cookie'] as string;
|
||||
return setCookie.split(';')[0];
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'regress-test-'));
|
||||
const config = loadConfig({ DATA_DIR: dataDir, PUBLIC_URL: 'http://localhost:8096' } as NodeJS.ProcessEnv);
|
||||
app = await buildApp({ config, dbPath: ':memory:', logger: false });
|
||||
|
||||
// Seed places directly rather than hitting the real BTC Map API in tests.
|
||||
for (const p of [PLACE_A, PLACE_B, PLACE_C]) {
|
||||
app.ctx.db
|
||||
.prepare('INSERT INTO places (id, lat, lon, name, synced_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(p.id, p.lat, p.lon, p.name, Math.floor(Date.now() / 1000));
|
||||
}
|
||||
|
||||
cookieA = await login(skA);
|
||||
cookieB = await login(skB);
|
||||
await app.inject({ method: 'POST', url: '/api/auth/team', headers: { cookie: cookieA }, payload: { team: 'orange' } });
|
||||
await app.inject({ method: 'POST', url: '/api/auth/team', headers: { cookie: cookieB }, payload: { team: 'green' } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('team selection', () => {
|
||||
it('reports the chosen team on /api/auth/me', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie: cookieA } });
|
||||
expect(res.json().team).toBe('orange');
|
||||
});
|
||||
|
||||
it('rejects switching teams once chosen', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/team',
|
||||
headers: { cookie: cookieA },
|
||||
payload: { team: 'green' },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claiming', () => {
|
||||
it('rejects claiming when too far away', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/places/${PLACE_A.id}/claim`,
|
||||
headers: { cookie: cookieA },
|
||||
payload: { lat: FAR_LAT, lon: FAR_LON },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('claims a neutral place when physically present', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/places/${PLACE_A.id}/claim`,
|
||||
headers: { cookie: cookieA },
|
||||
payload: { lat: PLACE_A.lat, lon: PLACE_A.lon },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().team).toBe('orange');
|
||||
});
|
||||
|
||||
it('rejects re-claiming your own team\'s place', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/places/${PLACE_A.id}/claim`,
|
||||
headers: { cookie: cookieA },
|
||||
payload: { lat: PLACE_A.lat, lon: PLACE_A.lon },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('lets the rival team capture it, flipping ownership', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/places/${PLACE_A.id}/claim`,
|
||||
headers: { cookie: cookieB },
|
||||
payload: { lat: PLACE_A.lat, lon: PLACE_A.lon },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().team).toBe('green');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linking and fields', () => {
|
||||
beforeAll(async () => {
|
||||
// Reset place A back to orange, then claim B and C for orange too.
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/places/${PLACE_A.id}/claim`,
|
||||
headers: { cookie: cookieA },
|
||||
payload: { lat: PLACE_A.lat, lon: PLACE_A.lon },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/places/${PLACE_B.id}/claim`,
|
||||
headers: { cookie: cookieA },
|
||||
payload: { lat: PLACE_B.lat, lon: PLACE_B.lon },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/places/${PLACE_C.id}/claim`,
|
||||
headers: { cookie: cookieA },
|
||||
payload: { lat: PLACE_C.lat, lon: PLACE_C.lon },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects linking places not both claimed by your team', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/links',
|
||||
headers: { cookie: cookieB },
|
||||
payload: { fromPlaceId: PLACE_A.id, toPlaceId: PLACE_B.id, lat: PLACE_A.lat, lon: PLACE_A.lon },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('creates a link when physically at the origin and both places are friendly', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/links',
|
||||
headers: { cookie: cookieA },
|
||||
payload: { fromPlaceId: PLACE_A.id, toPlaceId: PLACE_B.id, lat: PLACE_A.lat, lon: PLACE_A.lon },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects a duplicate link', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/links',
|
||||
headers: { cookie: cookieA },
|
||||
payload: { fromPlaceId: PLACE_B.id, toPlaceId: PLACE_A.id, lat: PLACE_B.lat, lon: PLACE_B.lon },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('forms a field once the triangle closes', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/links',
|
||||
headers: { cookie: cookieA },
|
||||
payload: { fromPlaceId: PLACE_B.id, toPlaceId: PLACE_C.id, lat: PLACE_B.lat, lon: PLACE_B.lon },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/links',
|
||||
headers: { cookie: cookieA },
|
||||
payload: { fromPlaceId: PLACE_C.id, toPlaceId: PLACE_A.id, lat: PLACE_C.lat, lon: PLACE_C.lon },
|
||||
});
|
||||
|
||||
const fieldsRes = await app.inject({ method: 'GET', url: '/api/fields' });
|
||||
const fields = fieldsRes.json();
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0].team).toBe('orange');
|
||||
|
||||
const scoreRes = await app.inject({ method: 'GET', url: '/api/score' });
|
||||
const orange = scoreRes.json().find((s: { team: string }) => s.team === 'orange');
|
||||
expect(orange.fields).toBe(1);
|
||||
expect(orange.areaKm2).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('recapturing a corner tears the field down', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/places/${PLACE_A.id}/claim`,
|
||||
headers: { cookie: cookieB },
|
||||
payload: { lat: PLACE_A.lat, lon: PLACE_A.lon },
|
||||
});
|
||||
const fieldsRes = await app.inject({ method: 'GET', url: '/api/fields' });
|
||||
expect(fieldsRes.json()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Config } from './config.js';
|
||||
import { openDatabase, type DB } from './db/database.js';
|
||||
import nostrAuth from './plugins/nostr-auth.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import placesRoutes from './routes/places.js';
|
||||
import claimsRoutes from './routes/claims.js';
|
||||
import linksRoutes from './routes/links.js';
|
||||
import fieldsRoutes from './routes/fields.js';
|
||||
import syncRoutes from './routes/sync.js';
|
||||
|
||||
export interface AppContext {
|
||||
config: Config;
|
||||
db: DB;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
ctx: AppContext;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config: Config;
|
||||
dbPath?: string; // override for tests (':memory:')
|
||||
logger?: boolean;
|
||||
}
|
||||
|
||||
export async function buildApp(opts: BuildAppOptions): Promise<FastifyInstance> {
|
||||
const { config } = opts;
|
||||
const db = openDatabase(opts.dbPath ?? join(config.DATA_DIR, 'regress.sqlite3'));
|
||||
|
||||
const app = Fastify({ logger: opts.logger ?? true, bodyLimit: 1 * 1024 * 1024 });
|
||||
|
||||
const ctx: AppContext = { config, db };
|
||||
app.decorate('ctx', ctx);
|
||||
|
||||
await app.register(cookie);
|
||||
await app.register(cors, { origin: true, credentials: true, methods: ['GET', 'POST', 'DELETE'] });
|
||||
await app.register(nostrAuth, { db, config });
|
||||
|
||||
app.get('/api/health', async () => ({ status: 'ok' }));
|
||||
|
||||
await app.register(authRoutes);
|
||||
await app.register(placesRoutes);
|
||||
await app.register(claimsRoutes);
|
||||
await app.register(linksRoutes);
|
||||
await app.register(fieldsRoutes);
|
||||
await app.register(syncRoutes);
|
||||
|
||||
const staticDir = config.STATIC_DIR;
|
||||
if (staticDir && existsSync(staticDir)) {
|
||||
await app.register(fastifyStatic, { root: staticDir });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.raw.url?.startsWith('/api/')) {
|
||||
return reply.code(404).send({ error: 'not found' });
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(8096),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
DATA_DIR: z.string().default('./data'),
|
||||
STATIC_DIR: z.string().optional(),
|
||||
PUBLIC_URL: z.string().url().default('http://localhost:8096'),
|
||||
NIP98_MAX_SKEW_SECS: z.coerce.number().default(60),
|
||||
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
||||
// Default sync area: Madeira, Portugal (Funchal-centered radius covering the whole island).
|
||||
BTCMAP_CENTER_LAT: z.coerce.number().default(32.7607),
|
||||
BTCMAP_CENTER_LON: z.coerce.number().default(-16.9595),
|
||||
BTCMAP_RADIUS_KM: z.coerce.number().default(45),
|
||||
// How close (in meters) a player must be to a place to claim it or link from it.
|
||||
CLAIM_RADIUS_METERS: z.coerce.number().default(40),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
return envSchema.parse(env);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { migrations } from './migrations.js';
|
||||
|
||||
export type DB = Database.Database;
|
||||
|
||||
export function openDatabase(path: string): DB {
|
||||
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
||||
const db = new Database(path);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function migrate(db: DB): void {
|
||||
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)');
|
||||
const applied = new Set(
|
||||
db.prepare('SELECT id FROM schema_migrations').all().map((r) => (r as { id: number }).id),
|
||||
);
|
||||
const record = db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)');
|
||||
for (const m of migrations) {
|
||||
if (applied.has(m.id)) continue;
|
||||
db.transaction(() => {
|
||||
db.exec(m.sql);
|
||||
record.run(m.id, Math.floor(Date.now() / 1000));
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export interface Migration {
|
||||
id: number;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export const migrations: Migration[] = [
|
||||
{
|
||||
id: 1,
|
||||
sql: `
|
||||
CREATE TABLE users (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
team TEXT CHECK (team IN ('orange','green')),
|
||||
display_name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
pubkey TEXT NOT NULL REFERENCES users(pubkey),
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE auth_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
seen_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Places are synced from BTC Map (https://btcmap.org) — one row per BTC Map place id.
|
||||
-- This table is a local cache the game plays against; re-synced periodically.
|
||||
CREATE TABLE places (
|
||||
id INTEGER PRIMARY KEY,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT,
|
||||
address TEXT,
|
||||
osm_id TEXT,
|
||||
btcmap_updated_at TEXT,
|
||||
synced_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Current capture state of a place. One row per place — capturing overwrites/deletes it.
|
||||
CREATE TABLE claims (
|
||||
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
|
||||
team TEXT NOT NULL CHECK (team IN ('orange','green')),
|
||||
claimed_by_pubkey TEXT NOT NULL REFERENCES users(pubkey),
|
||||
claimed_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- A link only exists while both endpoints remain claimed by the same team;
|
||||
-- recapturing either endpoint tears down every link touching it (see routes/claims.ts).
|
||||
CREATE TABLE links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||
to_place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||
team TEXT NOT NULL CHECK (team IN ('orange','green')),
|
||||
created_by_pubkey TEXT NOT NULL REFERENCES users(pubkey),
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE (from_place_id, to_place_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_links_from ON links(from_place_id);
|
||||
CREATE INDEX idx_links_to ON links(to_place_id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
import { loadConfig } from './config.js';
|
||||
import { buildApp } from './app.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const app = await buildApp({ config });
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.PORT, host: config.HOST });
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import type { DB } from '../db/database.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { Nip98Error, verifyNip98 } from '../services/nip98.js';
|
||||
|
||||
export const SESSION_COOKIE = 'regress_session';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
requireAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
verifyNip98Request: (req: FastifyRequest) => string;
|
||||
createSession: (pubkey: string, reply: FastifyReply) => void;
|
||||
destroySession: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
}
|
||||
interface FastifyRequest {
|
||||
userPubkey?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NostrAuthOptions {
|
||||
db: DB;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
function nowSecs(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
export default fp(async function nostrAuth(app: FastifyInstance, opts: NostrAuthOptions) {
|
||||
const { db, config } = opts;
|
||||
|
||||
const insertAuthEvent = db.prepare('INSERT OR IGNORE INTO auth_events (event_id, seen_at) VALUES (?, ?)');
|
||||
const pruneAuthEvents = db.prepare('DELETE FROM auth_events WHERE seen_at < ?');
|
||||
const insertSession = db.prepare('INSERT INTO sessions (id, pubkey, created_at, expires_at) VALUES (?, ?, ?, ?)');
|
||||
const selectSession = db.prepare('SELECT pubkey, expires_at FROM sessions WHERE id = ?');
|
||||
const deleteSession = db.prepare('DELETE FROM sessions WHERE id = ?');
|
||||
const pruneSessions = db.prepare('DELETE FROM sessions WHERE expires_at < ?');
|
||||
|
||||
app.decorateRequest('userPubkey', null);
|
||||
|
||||
function candidateUrls(req: FastifyRequest): string[] {
|
||||
const publicOrigin = new URL(config.PUBLIC_URL).origin;
|
||||
const urls = [`${publicOrigin}${req.raw.url}`];
|
||||
const host = req.headers['x-forwarded-host'] ?? req.headers.host;
|
||||
if (host) {
|
||||
const proto = (req.headers['x-forwarded-proto'] as string | undefined) ?? 'http';
|
||||
urls.push(`${proto}://${host}${req.raw.url}`);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function verifyNip98Request(req: FastifyRequest): string {
|
||||
const body = req.body != null && typeof req.body === 'object'
|
||||
? Buffer.from(JSON.stringify(req.body))
|
||||
: null;
|
||||
return verifyNip98(req.headers.authorization, {
|
||||
allowedUrls: candidateUrls(req),
|
||||
method: req.method,
|
||||
body,
|
||||
maxSkewSecs: config.NIP98_MAX_SKEW_SECS,
|
||||
isReplay: (eventId) => {
|
||||
pruneAuthEvents.run(nowSecs() - config.NIP98_MAX_SKEW_SECS * 4);
|
||||
const inserted = insertAuthEvent.run(eventId, nowSecs()).changes;
|
||||
return inserted === 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
app.decorate('verifyNip98Request', verifyNip98Request);
|
||||
|
||||
app.decorate('createSession', (pubkey: string, reply: FastifyReply) => {
|
||||
pruneSessions.run(nowSecs());
|
||||
const id = randomBytes(32).toString('hex');
|
||||
insertSession.run(id, pubkey, nowSecs(), nowSecs() + config.SESSION_TTL_DAYS * 86400);
|
||||
reply.setCookie(SESSION_COOKIE, id, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: config.SESSION_TTL_DAYS * 86400,
|
||||
});
|
||||
});
|
||||
|
||||
app.decorate('destroySession', (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const id = req.cookies[SESSION_COOKIE];
|
||||
if (id) deleteSession.run(id);
|
||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
});
|
||||
|
||||
app.decorate('requireAuth', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const sessionId = req.cookies[SESSION_COOKIE];
|
||||
if (sessionId) {
|
||||
const row = selectSession.get(sessionId) as { pubkey: string; expires_at: number } | undefined;
|
||||
if (row && row.expires_at > nowSecs()) {
|
||||
req.userPubkey = row.pubkey;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (req.headers.authorization?.startsWith('Nostr ')) {
|
||||
try {
|
||||
req.userPubkey = verifyNip98Request(req);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err instanceof Nip98Error) {
|
||||
return reply.code(401).send({ error: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return reply.code(401).send({ error: 'not authenticated' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type Team = 'orange' | 'green';
|
||||
|
||||
export interface User {
|
||||
pubkey: string;
|
||||
team: Team | null;
|
||||
display_name: string | null;
|
||||
created_at: number;
|
||||
last_login_at: number;
|
||||
}
|
||||
|
||||
export interface Place {
|
||||
id: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
address: string | null;
|
||||
osm_id: string | null;
|
||||
btcmap_updated_at: string | null;
|
||||
synced_at: number;
|
||||
}
|
||||
|
||||
export interface Claim {
|
||||
place_id: number;
|
||||
team: Team;
|
||||
claimed_by_pubkey: string;
|
||||
claimed_at: number;
|
||||
}
|
||||
|
||||
export interface LinkRow {
|
||||
id: number;
|
||||
from_place_id: number;
|
||||
to_place_id: number;
|
||||
team: Team;
|
||||
created_by_pubkey: string;
|
||||
created_at: number;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user