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
+3335
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+223
View File
@@ -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);
});
});
+72
View File
@@ -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;
}
+23
View File
@@ -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);
}
+30
View File
@@ -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));
})();
}
}
+73
View File
@@ -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);
`,
},
];
+12
View File
@@ -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);
}
+113
View File
@@ -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' });
});
});
+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 };
});
}
+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;
}
+37
View File
@@ -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;
}
+18
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});