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,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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user