initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
18
server/package.json
Normal file
18
server/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "proximity-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"@types/ws": "^8.5.0"
|
||||
}
|
||||
}
|
||||
319
server/src/index.ts
Normal file
319
server/src/index.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Proximity — reference implementation of the backend API.
|
||||
*
|
||||
* Implements the exact contract in docs/backend-api-spec.md so the existing
|
||||
* Swift client works against it. In-memory state, no real auth verification,
|
||||
* no persistence. It is a reference for the interaction loop, not production.
|
||||
*
|
||||
* This version fixes the session→own-token mapping so a **two-simulator
|
||||
* mutual reveal** works end-to-end:
|
||||
*
|
||||
* - Each client authenticates → gets a session token.
|
||||
* - Each client registers its rotating anon token (POST /v1/token) with the
|
||||
* session in the Authorization header.
|
||||
* - Each client uploads its E2E public key (POST /v1/peer-key).
|
||||
* - A wave (POST /v1/wave) carries the *remote* token; the server derives
|
||||
* the sender's own token from the session and records a directional edge.
|
||||
* - When both directions exist, the server pushes `mutual` to both sockets.
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /v1/auth/exchange
|
||||
* POST /v1/auth/revoke
|
||||
* POST /v1/token
|
||||
* GET /v1/nearby
|
||||
* POST /v1/encounter
|
||||
* POST /v1/wave
|
||||
* POST /v1/peer-key (upload own key)
|
||||
* GET /v1/peer-key (fetch peer key)
|
||||
* POST /v1/block
|
||||
* POST /v1/message
|
||||
* WS /ws
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** sessionToken -> { account } */
|
||||
const sessions = new Map<string, { account: any }>();
|
||||
|
||||
/** anonToken -> { sessionToken, tier, geohash, socket } */
|
||||
const presence = new Map<string, {
|
||||
sessionToken: string;
|
||||
tier: number;
|
||||
geohash?: string;
|
||||
socket?: WebSocket;
|
||||
}>();
|
||||
|
||||
/** anonToken -> base64 X25519 public key */
|
||||
const publicKeys = new Map<string, string>();
|
||||
|
||||
/** directional wave edges: "fromToken:toToken" -> true */
|
||||
const waves = new Set<string>();
|
||||
|
||||
/** blocked: "tokenA:tokenB" both directions */
|
||||
const blocked = new Set<string>();
|
||||
|
||||
/** connectionID -> set of member tokens */
|
||||
const connections = new Map<string, Set<string>>();
|
||||
|
||||
let nextConnectionId = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sendJSON(res: any, status: number, body: unknown) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readBody(req: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
req.on("data", (c: Buffer) => (data += c));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(data ? JSON.parse(data) : {});
|
||||
} catch {
|
||||
reject(new Error("bad json"));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Extract the Bearer session token from the Authorization header. */
|
||||
function bearerToken(req: any): string | null {
|
||||
const auth = req.headers["authorization"] as string | undefined;
|
||||
if (!auth || !auth.startsWith("Bearer ")) return null;
|
||||
return auth.slice("Bearer ".length).trim();
|
||||
}
|
||||
|
||||
/** Resolve the caller's own anon token from their session. */
|
||||
function ownToken(req: any): string | null {
|
||||
const session = bearerToken(req);
|
||||
if (!session || !sessions.has(session)) return null;
|
||||
for (const [token, p] of presence) {
|
||||
if (p.sessionToken === session) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasMutual(from: string, to: string): boolean {
|
||||
return waves.has(`${from}:${to}`) && waves.has(`${to}:${from}`);
|
||||
}
|
||||
|
||||
function isBlocked(a: string, b: string): boolean {
|
||||
return blocked.has(`${a}:${b}`) || blocked.has(`${b}:${a}`);
|
||||
}
|
||||
|
||||
/** Push a server→client event to a token's socket. */
|
||||
function push(token: string, event: object) {
|
||||
const p = presence.get(token);
|
||||
if (p?.socket && p.socket.readyState === WebSocket.OPEN) {
|
||||
p.socket.send(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
const path = url.pathname;
|
||||
|
||||
try {
|
||||
// --- Auth ---
|
||||
if (path === "/v1/auth/exchange" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
const account = {
|
||||
id: `acct_${Math.random().toString(36).slice(2)}`,
|
||||
providerID: body.provider,
|
||||
displayName: body.provider === "apple" ? "Apple User" : "Google User",
|
||||
email: null,
|
||||
};
|
||||
const session = `sess_${Math.random().toString(36).slice(2)}`;
|
||||
sessions.set(session, { account });
|
||||
return sendJSON(res, 200, { token: session, account });
|
||||
}
|
||||
|
||||
if (path === "/v1/auth/revoke" && req.method === "POST") {
|
||||
const session = bearerToken(req);
|
||||
if (session) sessions.delete(session);
|
||||
return sendJSON(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// --- Presence ---
|
||||
if (path === "/v1/token" && req.method === "POST") {
|
||||
const session = bearerToken(req);
|
||||
if (!session || !sessions.has(session)) {
|
||||
return sendJSON(res, 401, { error: "unauthorized" });
|
||||
}
|
||||
const body = await readBody(req);
|
||||
const existing = presence.get(body.token) || {};
|
||||
presence.set(body.token, {
|
||||
...existing,
|
||||
sessionToken: session,
|
||||
tier: body.tier,
|
||||
geohash: body.geohash,
|
||||
});
|
||||
return sendJSON(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (path === "/v1/nearby" && req.method === "GET") {
|
||||
const geohash = url.searchParams.get("geohash") || "";
|
||||
const tier = Number(url.searchParams.get("tier"));
|
||||
const tokens: string[] = [];
|
||||
for (const [token, p] of presence) {
|
||||
if (p.geohash?.startsWith(geohash.slice(0, 4)) && p.tier >= tier) {
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
return sendJSON(res, 200, tokens);
|
||||
}
|
||||
|
||||
if (path === "/v1/encounter" && req.method === "POST") {
|
||||
await readBody(req); // correlation is out of scope for the reference
|
||||
return sendJSON(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// --- Reveal flow ---
|
||||
if (path === "/v1/wave" && req.method === "POST") {
|
||||
const from = ownToken(req);
|
||||
const body = await readBody(req);
|
||||
const to = body.token; // the *remote* token the client is waving at
|
||||
if (!from || !to || from === to) {
|
||||
return sendJSON(res, 400, { error: "invalid wave" });
|
||||
}
|
||||
if (isBlocked(from, to)) {
|
||||
return sendJSON(res, 200, { ok: true, mutual: false });
|
||||
}
|
||||
|
||||
waves.add(`${from}:${to}`);
|
||||
|
||||
const mutual = hasMutual(from, to);
|
||||
if (mutual) {
|
||||
// Create (or reuse) a connection for messaging.
|
||||
let connectionID: string | null = null;
|
||||
for (const [id, members] of connections) {
|
||||
if (members.has(from) && members.has(to)) {
|
||||
connectionID = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!connectionID) {
|
||||
connectionID = `conn_${nextConnectionId++}`;
|
||||
connections.set(connectionID, new Set([from, to]));
|
||||
}
|
||||
|
||||
// Both waved. Notify both sides in real time.
|
||||
push(from, { type: "mutual", remoteToken: to, connectionID });
|
||||
push(to, { type: "mutual", remoteToken: from, connectionID });
|
||||
}
|
||||
return sendJSON(res, 200, { ok: true, mutual });
|
||||
}
|
||||
|
||||
// Upload own public key (called when sending a wave / entering reveal).
|
||||
if (path === "/v1/peer-key" && req.method === "POST") {
|
||||
const from = ownToken(req);
|
||||
const body = await readBody(req);
|
||||
if (!from) return sendJSON(res, 401, { error: "unauthorized" });
|
||||
publicKeys.set(from, body.publicKey);
|
||||
return sendJSON(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// Fetch a peer's public key (only within a mutual relationship).
|
||||
if (path === "/v1/peer-key" && req.method === "GET") {
|
||||
const from = ownToken(req);
|
||||
const to = url.searchParams.get("token") || "";
|
||||
if (!from || !hasMutual(from, to)) {
|
||||
return sendJSON(res, 403, { error: "not mutual" });
|
||||
}
|
||||
const key = publicKeys.get(to);
|
||||
if (!key) return sendJSON(res, 404, { error: "no key" });
|
||||
return sendJSON(res, 200, { publicKey: key });
|
||||
}
|
||||
|
||||
if (path === "/v1/block" && req.method === "POST") {
|
||||
const from = ownToken(req);
|
||||
const body = await readBody(req);
|
||||
if (!from) return sendJSON(res, 401, { error: "unauthorized" });
|
||||
blocked.add(`${from}:${body.token}`);
|
||||
blocked.add(`${body.token}:${from}`);
|
||||
waves.delete(`${from}:${body.token}`);
|
||||
waves.delete(`${body.token}:${from}`);
|
||||
return sendJSON(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// --- Messaging ---
|
||||
if (path === "/v1/message" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
const members = connections.get(body.connectionID);
|
||||
if (!members) return sendJSON(res, 404, { error: "no connection" });
|
||||
for (const token of members) {
|
||||
if (token !== body.senderID) {
|
||||
push(token, { type: "message", message: body });
|
||||
}
|
||||
}
|
||||
return sendJSON(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
return sendJSON(res, 404, { error: "not found" });
|
||||
} catch {
|
||||
return sendJSON(res, 400, { error: "bad request" });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket — bind the socket to the caller's anon token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on("connection", (socket, req) => {
|
||||
// The client connects with `Authorization: Bearer <session>`. Bind this
|
||||
// socket to whichever anon token the session currently owns.
|
||||
const session = bearerToken(req as any);
|
||||
let bound = false;
|
||||
if (session) {
|
||||
for (const [token, p] of presence) {
|
||||
if (p.sessionToken === session) {
|
||||
p.socket = socket;
|
||||
bound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
let msg: any;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === "ping") {
|
||||
socket.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
// Clear the socket binding if it's still ours.
|
||||
if (bound) {
|
||||
for (const [, p] of presence) {
|
||||
if (p.socket === socket) p.socket = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Proximity reference server on :${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user