Scaffold Regress: Fastify+SQLite server and Vue frontend for the Ingress-style BTC Map capture game
- Server: NIP-98 nostr auth (session cookies), BTC Map sync, claim/link/field routes, physical-presence checks via geolocation distance. 31 tests passing. - Frontend: Leaflet map, team pick, claim/link UI, Archipelago identity bridge vendored (nostr-provider.js) for dashboard launch support. - Verified end-to-end against the live BTC Map API (167 real Madeira places) and via genuine NIP-98-signed HTTP requests against the built app.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Hostname or IP your users will reach this box on (no scheme, no port)
|
||||
PUBLIC_HOST=localhost
|
||||
|
||||
# Where the Regress UI/API is reachable from browsers
|
||||
PUBLIC_URL=http://${PUBLIC_HOST}:8096
|
||||
|
||||
# How close (meters) a player must physically be to a place to claim it or
|
||||
# link from it. Mirrors Ingress's "you must be at the portal" rule.
|
||||
CLAIM_RADIUS_METERS=40
|
||||
|
||||
# BTC Map sync area — defaults to Madeira, Portugal (Funchal-centered).
|
||||
# See DESIGN.md for why this scope was chosen first.
|
||||
BTCMAP_CENTER_LAT=32.7607
|
||||
BTCMAP_CENTER_LON=-16.9595
|
||||
BTCMAP_RADIUS_KM=45
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
server/data/
|
||||
frontend/dist/
|
||||
.DS_Store
|
||||
@@ -1,3 +1,56 @@
|
||||
# regress
|
||||
# Regress
|
||||
|
||||
Nostr-based location game on Archipelago — recreates BTC Maps unclaimed businesses as capturable portals, starting with Madeira
|
||||
A location-based capture game in the spirit of *Ingress*, built as a nostr-native
|
||||
web app. Real Bitcoin-accepting businesses from [BTC Map](https://btcmap.org)
|
||||
stand in for Ingress's portals — players physically visit them to claim them
|
||||
for their team (orange or green), then link claimed places into triangular
|
||||
control fields. First scope: **Madeira, Portugal**.
|
||||
|
||||
See [`DESIGN.md`](./DESIGN.md) for the full research/design writeup.
|
||||
|
||||
## Stack
|
||||
|
||||
- `server/` — Fastify + better-sqlite3 + nostr-tools (TypeScript), same pattern
|
||||
as [podsteadr](http://146.59.87.168:3000/ssmithx/podsteadr).
|
||||
- `frontend/` — Vue 3 + Vite + Pinia + Tailwind + Leaflet.
|
||||
- Auth is NIP-98 (signed-event login → session cookie), same as podsteadr.
|
||||
`frontend/public/nostr-provider.js` is vendored so the app can also be
|
||||
launched identity-aware from inside an Archipelago dashboard, the same way
|
||||
as podsteadr — see that repo's README for how the registration side works.
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
# server
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # http://localhost:8096
|
||||
|
||||
# frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173, proxies /api to :8096
|
||||
```
|
||||
|
||||
On first run, the `places` table is empty — call `POST /api/sync` (or click
|
||||
"Sync from BTC Map" in the UI) to pull the current Madeira dataset from BTC
|
||||
Map's public API.
|
||||
|
||||
## Game rules (v0)
|
||||
|
||||
- Claiming or linking from a place requires being within `CLAIM_RADIUS_METERS`
|
||||
(default 40m) of it, via browser geolocation — no claiming from the couch.
|
||||
- Claiming an enemy-held place captures it for your team and tears down any
|
||||
links running through it.
|
||||
- A link can be created between any two places your team currently holds,
|
||||
as long as you're standing at the origin place — no maximum link distance.
|
||||
- Three mutually-linked, same-team places form a field (computed live, not
|
||||
stored) — see `server/src/services/fields.ts`.
|
||||
- Team choice is one-way once made.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm test
|
||||
```
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Regress</title>
|
||||
<!-- No-op outside an Archipelago iframe (see nostr-provider.js's own
|
||||
window === window.top guard) — safe to always include. Provides
|
||||
window.nostr + auto sign-in via the node's selected nostr identity
|
||||
when opened inside the Archipelago shell. -->
|
||||
<script src="/nostr-provider.js" data-session-url="/api/auth/login" data-session-mode="cookie" data-me-url="/api/auth/me" data-health-url="/api/health"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2719
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "regress-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.2.0",
|
||||
"vue-tsc": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* NIP-07 Nostr Provider Shim — Archipelago
|
||||
*
|
||||
* Vendored from archy/neode-ui/public/nostr-provider.js (generalized version).
|
||||
* Provides window.nostr (NIP-07) for iframe apps launched inside the
|
||||
* Archipelago shell, bridging signing requests via postMessage to the
|
||||
* parent frame, which relays them to the Archipelago node's identity
|
||||
* manager. Auto sign-in: does NIP-98 auth against this app's own backend,
|
||||
* then reloads so the app picks up the valid session.
|
||||
*
|
||||
* Not vendored via an Archipelago manifest hook (podsteadr isn't an
|
||||
* orchestrator-managed package — see neode-ui's EXTERNAL_URLS /
|
||||
* WEB_ONLY_APP-style "external web app" registration instead), so this
|
||||
* copy won't auto-update with archy's OTA releases. Re-sync by hand from
|
||||
* archy/neode-ui/public/nostr-provider.js if that file changes.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__archipelagoNostr) return;
|
||||
window.__archipelagoNostr = true;
|
||||
if (window === window.top) return;
|
||||
|
||||
var pending = {}, nextId = 1;
|
||||
|
||||
function request(method, params) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var id = nextId++;
|
||||
pending[id] = { resolve: resolve, reject: reject };
|
||||
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
|
||||
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (e) {
|
||||
if (!e.data || e.data.type !== 'nostr-response') return;
|
||||
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
|
||||
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
|
||||
});
|
||||
|
||||
window.nostr = {
|
||||
getPublicKey: function () { return request('getPublicKey'); },
|
||||
signEvent: function (ev) { return request('signEvent', { event: ev }); },
|
||||
sign: function (ev) { return request('signEvent', { event: ev }); },
|
||||
getRelays: function () { return request('getRelays'); },
|
||||
nip04: {
|
||||
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
|
||||
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
|
||||
},
|
||||
nip44: {
|
||||
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
|
||||
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
|
||||
},
|
||||
};
|
||||
|
||||
// --- Loading Overlay ---
|
||||
var overlay = null;
|
||||
|
||||
function showLoader(message) {
|
||||
if (overlay) return;
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'archipelago-auth-overlay';
|
||||
overlay.innerHTML =
|
||||
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
|
||||
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
|
||||
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
|
||||
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
|
||||
'</svg>' +
|
||||
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
|
||||
'</div>';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
|
||||
document.head.appendChild(style);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function updateLoader(message) {
|
||||
if (!overlay) return;
|
||||
var txt = overlay.querySelector('div > div');
|
||||
if (txt) txt.textContent = message;
|
||||
}
|
||||
|
||||
function hideLoader() {
|
||||
if (overlay) { overlay.remove(); overlay = null; }
|
||||
}
|
||||
|
||||
// --- Per-app config (data-* attrs on the injected <script> tag). Defaults
|
||||
// match indeedhub's original hardcoded values, so apps that don't set any
|
||||
// overrides keep behaving exactly as before.
|
||||
var scriptEl = document.currentScript;
|
||||
var ds = (scriptEl && scriptEl.dataset) || {};
|
||||
var cfg = {
|
||||
healthUrl: ds.healthUrl || '/api/nostr-auth/health',
|
||||
sessionUrl: ds.sessionUrl || '/api/auth/nostr/session',
|
||||
sessionMethod: ds.sessionMethod || 'POST',
|
||||
// 'token' (default): login response is JSON {accessToken, refreshToken};
|
||||
// stored in sessionStorage, matches indeedhub.
|
||||
// 'cookie': server sets the session cookie directly on the login
|
||||
// response (Set-Cookie) — nothing to store client-side, just reload.
|
||||
sessionMode: ds.sessionMode || 'token',
|
||||
// Optional: for cookie-mode apps, check this endpoint first and skip
|
||||
// the NIP-98 handshake entirely if it reports already-authenticated
|
||||
// (401 otherwise) — avoids re-running sign-in on every iframe reload.
|
||||
meUrl: ds.meUrl || null,
|
||||
};
|
||||
|
||||
// --- Direct NIP-98 Auth ---
|
||||
var authDone = false;
|
||||
|
||||
function performNip98Auth(pubkey) {
|
||||
var healthUrl = window.location.origin + cfg.healthUrl;
|
||||
var sessionUrl = window.location.origin + cfg.sessionUrl;
|
||||
|
||||
// 1. Check if API backend is reachable (3s timeout)
|
||||
var hc = new AbortController();
|
||||
var ht = setTimeout(function () { hc.abort(); }, 3000);
|
||||
|
||||
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
|
||||
clearTimeout(ht);
|
||||
if (!r.ok) throw new Error('Health ' + r.status);
|
||||
|
||||
// 2. API is up — show loader and do NIP-98
|
||||
showLoader('Signing in with Nostr...');
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
var event = {
|
||||
kind: 27235, created_at: now, content: '', pubkey: pubkey,
|
||||
tags: [['u', sessionUrl], ['method', cfg.sessionMethod]]
|
||||
};
|
||||
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
|
||||
return window.nostr.signEvent(event);
|
||||
|
||||
}).then(function (signed) {
|
||||
updateLoader('Creating session...');
|
||||
var ac = new AbortController();
|
||||
setTimeout(function () { ac.abort(); }, 10000);
|
||||
return fetch(sessionUrl, {
|
||||
method: cfg.sessionMethod,
|
||||
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
|
||||
signal: ac.signal
|
||||
});
|
||||
|
||||
}).then(function (res) {
|
||||
console.log('[nostr-provider] NIP-98: response', res.status);
|
||||
if (!res.ok) throw new Error('Auth failed: ' + res.status);
|
||||
if (cfg.sessionMode === 'cookie') {
|
||||
// Session cookie already landed via Set-Cookie on this response.
|
||||
updateLoader('Signed in! Loading...');
|
||||
console.log('[nostr-provider] NIP-98: success (cookie session), reloading...');
|
||||
setTimeout(function () { window.location.reload(); }, 400);
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
|
||||
}).then(function (data) {
|
||||
if (!data) return; // cookie-mode: handled above, nothing left to do
|
||||
if (data.accessToken) {
|
||||
sessionStorage.setItem('nostr_token', data.accessToken);
|
||||
sessionStorage.setItem('nostr_pubkey', pubkey);
|
||||
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
|
||||
updateLoader('Signed in! Loading...');
|
||||
console.log('[nostr-provider] NIP-98: success, reloading...');
|
||||
setTimeout(function () { window.location.reload(); }, 400);
|
||||
} else {
|
||||
hideLoader(); authDone = false;
|
||||
}
|
||||
|
||||
}).catch(function (err) {
|
||||
hideLoader(); authDone = false;
|
||||
var msg = err.message || String(err);
|
||||
if (msg.indexOf('abort') > -1) msg = 'API timeout';
|
||||
console.warn('[nostr-provider] NIP-98 skipped:', msg);
|
||||
});
|
||||
}
|
||||
|
||||
function doNip98Auth(pubkey) {
|
||||
if (authDone) return;
|
||||
authDone = true;
|
||||
|
||||
if (cfg.meUrl) {
|
||||
// Already-authenticated check first — avoids re-running the NIP-98
|
||||
// handshake (and its reload) on every iframe load for cookie-session
|
||||
// apps, where there's no client-visible token to check locally.
|
||||
fetch(window.location.origin + cfg.meUrl, { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (r.ok) {
|
||||
console.log('[nostr-provider] Already authenticated (meUrl ok), skipping NIP-98');
|
||||
authDone = false;
|
||||
return;
|
||||
}
|
||||
performNip98Auth(pubkey);
|
||||
})
|
||||
.catch(function () { performNip98Auth(pubkey); });
|
||||
return;
|
||||
}
|
||||
|
||||
performNip98Auth(pubkey);
|
||||
}
|
||||
|
||||
// Listen for identity from parent Archipelago frame
|
||||
window.addEventListener('message', function (e) {
|
||||
if (!e.data || e.data.type !== 'archipelago:identity') return;
|
||||
var pk = e.data.nostr_pubkey;
|
||||
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
|
||||
if (!pk) return;
|
||||
|
||||
// Skip if already signed in with a real token (not mock)
|
||||
try {
|
||||
var token = sessionStorage.getItem('nostr_token');
|
||||
if (token && token.indexOf('mock-') === -1) {
|
||||
console.log('[nostr-provider] Already signed in with real token');
|
||||
return;
|
||||
}
|
||||
} catch (x) {}
|
||||
|
||||
setTimeout(function () { doNip98Auth(pk); }, 1500);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import LoginView from './views/LoginView.vue';
|
||||
import TeamPickView from './views/TeamPickView.vue';
|
||||
import MapView from './views/MapView.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
onMounted(() => auth.ensureLoaded());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full bg-neutral-950 text-white">
|
||||
<div v-if="!auth.loaded" class="flex h-full items-center justify-center text-neutral-400">
|
||||
Loading…
|
||||
</div>
|
||||
<LoginView v-else-if="!auth.pubkey" />
|
||||
<TeamPickView v-else-if="!auth.team" />
|
||||
<MapView v-else />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
// Thin fetch wrapper for the Regress API (session cookie auth).
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
message = ((await res.json()) as { error?: string }).error ?? message;
|
||||
} catch {
|
||||
/* keep statusText */
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
request<T>('POST', path, body, headers),
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
// Browser geolocation — required for claiming/linking (physical presence, Ingress-style).
|
||||
|
||||
export interface Position {
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracy: number;
|
||||
}
|
||||
|
||||
export function getCurrentPosition(): Promise<Position> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation is not available in this browser'));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude, accuracy: pos.coords.accuracy }),
|
||||
(err) => reject(new Error(`Could not get your location: ${err.message}`)),
|
||||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// NIP-07 browser extension bridge + NIP-98 header construction.
|
||||
// Identical contract to podsteadr's — also satisfied by nostr-provider.js's
|
||||
// window.nostr shim when running inside the Archipelago dashboard.
|
||||
|
||||
export interface UnsignedEvent {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
}
|
||||
|
||||
export interface SignedEvent extends UnsignedEvent {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
interface Nip07Provider {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: Nip07Provider;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasNip07(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.nostr;
|
||||
}
|
||||
|
||||
export function nip07(): Nip07Provider {
|
||||
if (!window.nostr) throw new Error('No NIP-07 nostr extension found');
|
||||
return window.nostr;
|
||||
}
|
||||
|
||||
/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */
|
||||
export async function buildNip98Header(url: string, method: string): Promise<string> {
|
||||
const event = await nip07().signEvent({
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method],
|
||||
],
|
||||
});
|
||||
return `Nostr ${btoa(JSON.stringify(event))}`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).use(createPinia()).mount('#app');
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { buildNip98Header, hasNip07 } from '../lib/nip07';
|
||||
|
||||
export type Team = 'orange' | 'green';
|
||||
|
||||
interface Me {
|
||||
pubkey: string;
|
||||
displayName: string | null;
|
||||
team: Team | null;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
pubkey: null as string | null,
|
||||
displayName: null as string | null,
|
||||
team: null as Team | null,
|
||||
loaded: false,
|
||||
}),
|
||||
actions: {
|
||||
async ensureLoaded() {
|
||||
if (this.loaded) return;
|
||||
try {
|
||||
const me = await api.get<Me>('/api/auth/me');
|
||||
this.pubkey = me.pubkey;
|
||||
this.displayName = me.displayName;
|
||||
this.team = me.team;
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401)) throw err;
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
},
|
||||
async login() {
|
||||
if (!hasNip07()) throw new Error('No nostr extension found — install Alby or nos2x first.');
|
||||
const url = `${location.origin}/api/auth/login`;
|
||||
const header = await buildNip98Header(url, 'POST');
|
||||
const res = await api.post<{ pubkey: string; team: Team | null }>(
|
||||
'/api/auth/login',
|
||||
undefined,
|
||||
{ authorization: header },
|
||||
);
|
||||
this.pubkey = res.pubkey;
|
||||
this.team = res.team;
|
||||
},
|
||||
async logout() {
|
||||
await api.post('/api/auth/logout');
|
||||
this.pubkey = null;
|
||||
this.displayName = null;
|
||||
this.team = null;
|
||||
},
|
||||
async chooseTeam(team: Team) {
|
||||
const res = await api.post<{ team: Team }>('/api/auth/team', { team });
|
||||
this.team = res.team;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { api } from '../lib/api';
|
||||
import { getCurrentPosition } from '../lib/geo';
|
||||
import type { Team } from './auth';
|
||||
|
||||
export interface Place {
|
||||
id: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
address: string | null;
|
||||
osm_id: string | null;
|
||||
claim_team: Team | null;
|
||||
claimed_by_pubkey: string | null;
|
||||
claimed_at: number | null;
|
||||
}
|
||||
|
||||
export interface LinkRow {
|
||||
id: number;
|
||||
from_place_id: number;
|
||||
to_place_id: number;
|
||||
team: Team;
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
team: Team;
|
||||
placeIds: [number, number, number];
|
||||
areaKm2: number;
|
||||
}
|
||||
|
||||
export interface TeamScore {
|
||||
team: Team;
|
||||
claimedPlaces: number;
|
||||
links: number;
|
||||
fields: number;
|
||||
areaKm2: number;
|
||||
}
|
||||
|
||||
export const useGameStore = defineStore('game', {
|
||||
state: () => ({
|
||||
places: [] as Place[],
|
||||
links: [] as LinkRow[],
|
||||
fields: [] as Field[],
|
||||
scores: [] as TeamScore[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async refreshAll() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const [places, links, fields, scores] = await Promise.all([
|
||||
api.get<Place[]>('/api/places'),
|
||||
api.get<LinkRow[]>('/api/links'),
|
||||
api.get<Field[]>('/api/fields'),
|
||||
api.get<TeamScore[]>('/api/score'),
|
||||
]);
|
||||
this.places = places;
|
||||
this.links = links;
|
||||
this.fields = fields;
|
||||
this.scores = scores;
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Sync places from BTC Map — one-time/periodic bootstrap of the local place cache. */
|
||||
async syncPlaces() {
|
||||
await api.post('/api/sync');
|
||||
await this.refreshAll();
|
||||
},
|
||||
|
||||
async claim(placeId: number) {
|
||||
const pos = await getCurrentPosition();
|
||||
await api.post(`/api/places/${placeId}/claim`, { lat: pos.lat, lon: pos.lon });
|
||||
await this.refreshAll();
|
||||
},
|
||||
|
||||
async link(fromPlaceId: number, toPlaceId: number) {
|
||||
const pos = await getCurrentPosition();
|
||||
await api.post('/api/links', { fromPlaceId, toPlaceId, lat: pos.lat, lon: pos.lon });
|
||||
await this.refreshAll();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { hasNip07 } from '../lib/nip07';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const error = ref<string | null>(null);
|
||||
const loggingIn = ref(false);
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = null;
|
||||
loggingIn.value = true;
|
||||
try {
|
||||
await auth.login();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loggingIn.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<h1 class="text-3xl font-bold">Regress</h1>
|
||||
<p class="max-w-sm text-neutral-400">
|
||||
Claim real Bitcoin-accepting businesses for your team. Log in with Nostr to start.
|
||||
</p>
|
||||
<button
|
||||
class="rounded-lg bg-white px-6 py-3 font-semibold text-black transition hover:bg-neutral-200 disabled:opacity-50"
|
||||
:disabled="loggingIn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loggingIn ? 'Signing in…' : 'Log in with Nostr' }}
|
||||
</button>
|
||||
<p v-if="!hasNip07()" class="text-sm text-neutral-500">
|
||||
No NIP-07 extension detected — install Alby or nos2x, or open this app from the Archipelago dashboard.
|
||||
</p>
|
||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import L from 'leaflet';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useGameStore } from '../stores/game';
|
||||
import type { Place } from '../stores/game';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const game = useGameStore();
|
||||
|
||||
const mapEl = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
let markersLayer: L.LayerGroup | null = null;
|
||||
let linksLayer: L.LayerGroup | null = null;
|
||||
let fieldsLayer: L.LayerGroup | null = null;
|
||||
|
||||
const selectedPlaceId = ref<number | null>(null);
|
||||
const linkSourceId = ref<number | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const actionBusy = ref(false);
|
||||
|
||||
const selectedPlace = computed<Place | null>(
|
||||
() => game.places.find((p) => p.id === selectedPlaceId.value) ?? null,
|
||||
);
|
||||
const linkSourcePlace = computed<Place | null>(
|
||||
() => game.places.find((p) => p.id === linkSourceId.value) ?? null,
|
||||
);
|
||||
|
||||
const TEAM_COLOR: Record<string, string> = { orange: '#f97316', green: '#22c55e' };
|
||||
const NEUTRAL_COLOR = '#9ca3af';
|
||||
|
||||
function colorFor(team: string | null): string {
|
||||
return team ? TEAM_COLOR[team] : NEUTRAL_COLOR;
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
if (!map || !markersLayer || !linksLayer || !fieldsLayer) return;
|
||||
markersLayer.clearLayers();
|
||||
linksLayer.clearLayers();
|
||||
fieldsLayer.clearLayers();
|
||||
|
||||
const coordsById = new Map(game.places.map((p) => [p.id, p]));
|
||||
|
||||
for (const place of game.places) {
|
||||
const isSource = place.id === linkSourceId.value;
|
||||
const marker = L.circleMarker([place.lat, place.lon], {
|
||||
radius: isSource ? 10 : 7,
|
||||
color: isSource ? '#ffffff' : colorFor(place.claim_team),
|
||||
weight: isSource ? 3 : 2,
|
||||
fillColor: colorFor(place.claim_team),
|
||||
fillOpacity: 0.85,
|
||||
});
|
||||
marker.on('click', () => onMarkerClick(place));
|
||||
marker.bindTooltip(place.name || `Place ${place.id}`, { direction: 'top' });
|
||||
markersLayer.addLayer(marker);
|
||||
}
|
||||
|
||||
for (const link of game.links) {
|
||||
const from = coordsById.get(link.from_place_id);
|
||||
const to = coordsById.get(link.to_place_id);
|
||||
if (!from || !to) continue;
|
||||
linksLayer.addLayer(
|
||||
L.polyline(
|
||||
[
|
||||
[from.lat, from.lon],
|
||||
[to.lat, to.lon],
|
||||
],
|
||||
{ color: TEAM_COLOR[link.team], weight: 2, opacity: 0.8 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of game.fields) {
|
||||
const points = field.placeIds
|
||||
.map((id) => coordsById.get(id))
|
||||
.filter((p): p is Place => !!p)
|
||||
.map((p) => [p.lat, p.lon] as [number, number]);
|
||||
if (points.length !== 3) continue;
|
||||
fieldsLayer.addLayer(
|
||||
L.polygon(points, { color: TEAM_COLOR[field.team], weight: 1, fillOpacity: 0.15, stroke: false }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onMarkerClick(place: Place) {
|
||||
selectedPlaceId.value = place.id;
|
||||
actionError.value = null;
|
||||
}
|
||||
|
||||
async function doClaim(place: Place) {
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.claim(place.id);
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startLink(place: Place) {
|
||||
linkSourceId.value = place.id;
|
||||
actionError.value = null;
|
||||
}
|
||||
|
||||
function cancelLink() {
|
||||
linkSourceId.value = null;
|
||||
}
|
||||
|
||||
async function completeLink(place: Place) {
|
||||
if (!linkSourceId.value) return;
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.link(linkSourceId.value, place.id);
|
||||
linkSourceId.value = null;
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doSync() {
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.syncPlaces();
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const orangeScore = computed(() => game.scores.find((s) => s.team === 'orange'));
|
||||
const greenScore = computed(() => game.scores.find((s) => s.team === 'green'));
|
||||
|
||||
onMounted(async () => {
|
||||
if (mapEl.value) {
|
||||
map = L.map(mapEl.value).setView([32.7607, -16.9595], 12);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
fieldsLayer = L.layerGroup().addTo(map);
|
||||
linksLayer = L.layerGroup().addTo(map);
|
||||
markersLayer = L.layerGroup().addTo(map);
|
||||
}
|
||||
await game.refreshAll();
|
||||
redraw();
|
||||
});
|
||||
|
||||
watch(() => [game.places, game.links, game.fields], redraw, { deep: true });
|
||||
watch(linkSourceId, redraw);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<header class="flex items-center justify-between gap-4 bg-neutral-900 px-4 py-2 text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-bold">Regress</span>
|
||||
<span class="flex items-center gap-1 text-orange-500">
|
||||
● {{ orangeScore?.claimedPlaces ?? 0 }} places · {{ orangeScore?.fields ?? 0 }} fields ·
|
||||
{{ (orangeScore?.areaKm2 ?? 0).toFixed(2) }} km²
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-green-500">
|
||||
● {{ greenScore?.claimedPlaces ?? 0 }} places · {{ greenScore?.fields ?? 0 }} fields ·
|
||||
{{ (greenScore?.areaKm2 ?? 0).toFixed(2) }} km²
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-neutral-400">
|
||||
{{ auth.displayName ?? auth.pubkey?.slice(0, 8) }} ·
|
||||
<span :class="auth.team === 'orange' ? 'text-orange-500' : 'text-green-500'">{{ auth.team }}</span>
|
||||
</span>
|
||||
<button class="rounded bg-neutral-800 px-3 py-1 hover:bg-neutral-700" :disabled="actionBusy" @click="doSync">
|
||||
Sync from BTC Map
|
||||
</button>
|
||||
<button class="rounded bg-neutral-800 px-3 py-1 hover:bg-neutral-700" @click="auth.logout()">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="relative flex-1">
|
||||
<div ref="mapEl" class="h-full w-full"></div>
|
||||
|
||||
<div
|
||||
v-if="linkSourcePlace"
|
||||
class="absolute left-4 top-4 z-[1000] rounded-lg bg-neutral-900/95 px-4 py-2 text-sm shadow-lg"
|
||||
>
|
||||
Linking from <strong>{{ linkSourcePlace.name }}</strong> — click a friendly place to complete, or
|
||||
<button class="ml-2 text-neutral-400 underline" @click="cancelLink">cancel</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedPlace"
|
||||
class="absolute right-4 top-4 z-[1000] w-72 rounded-lg bg-neutral-900/95 p-4 text-sm shadow-lg"
|
||||
>
|
||||
<div class="mb-1 flex items-start justify-between gap-2">
|
||||
<h2 class="font-bold">{{ selectedPlace.name || `Place ${selectedPlace.id}` }}</h2>
|
||||
<button class="text-neutral-500" @click="selectedPlaceId = null">✕</button>
|
||||
</div>
|
||||
<p v-if="selectedPlace.address" class="mb-2 text-neutral-400">{{ selectedPlace.address }}</p>
|
||||
<p class="mb-3">
|
||||
Status:
|
||||
<span v-if="!selectedPlace.claim_team" class="text-neutral-400">unclaimed</span>
|
||||
<span v-else :class="selectedPlace.claim_team === 'orange' ? 'text-orange-500' : 'text-green-500'">
|
||||
claimed by {{ selectedPlace.claim_team }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-if="selectedPlace.claim_team !== auth.team"
|
||||
class="rounded bg-white px-3 py-2 font-semibold text-black hover:bg-neutral-200 disabled:opacity-50"
|
||||
:disabled="actionBusy"
|
||||
@click="doClaim(selectedPlace)"
|
||||
>
|
||||
Claim for {{ auth.team }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="linkSourceId !== selectedPlace.id"
|
||||
class="rounded bg-neutral-800 px-3 py-2 hover:bg-neutral-700 disabled:opacity-50"
|
||||
:disabled="actionBusy"
|
||||
@click="startLink(selectedPlace)"
|
||||
>
|
||||
Link from here
|
||||
</button>
|
||||
<button
|
||||
v-if="linkSourceId && linkSourceId !== selectedPlace.id && selectedPlace.claim_team === auth.team"
|
||||
class="rounded bg-neutral-800 px-3 py-2 hover:bg-neutral-700 disabled:opacity-50"
|
||||
:disabled="actionBusy"
|
||||
@click="completeLink(selectedPlace)"
|
||||
>
|
||||
Complete link to here
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="actionError" class="mt-3 text-red-400">{{ actionError }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!game.places.length && !game.loading"
|
||||
class="absolute inset-0 z-[999] flex items-center justify-center bg-black/60"
|
||||
>
|
||||
<div class="rounded-lg bg-neutral-900 p-6 text-center">
|
||||
<p class="mb-3">No places loaded yet — pull the current Madeira dataset from BTC Map.</p>
|
||||
<button class="rounded bg-white px-4 py-2 font-semibold text-black" :disabled="actionBusy" @click="doSync">
|
||||
Sync from BTC Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import type { Team } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const error = ref<string | null>(null);
|
||||
const picking = ref(false);
|
||||
|
||||
async function pick(team: Team) {
|
||||
error.value = null;
|
||||
picking.value = true;
|
||||
try {
|
||||
await auth.chooseTeam(team);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
picking.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
|
||||
<h1 class="text-2xl font-bold">Pick your faction</h1>
|
||||
<p class="max-w-sm text-neutral-400">
|
||||
This choice is permanent — you can't switch teams later, so pick the side you're actually playing for.
|
||||
</p>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
class="rounded-lg bg-orange-500 px-8 py-4 text-lg font-bold text-black transition hover:bg-orange-400 disabled:opacity-50"
|
||||
:disabled="picking"
|
||||
@click="pick('orange')"
|
||||
>
|
||||
Orange
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-green-500 px-8 py-4 text-lg font-bold text-black transition hover:bg-green-400 disabled:opacity-50"
|
||||
:disabled="picking"
|
||||
@click="pick('green')"
|
||||
>
|
||||
Green
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
orange: { team: '#f97316' },
|
||||
green: { team: '#22c55e' },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"useDefineForClassFields": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8096',
|
||||
},
|
||||
},
|
||||
});
|
||||
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