initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project

This commit is contained in:
2026-08-12 00:22:01 +00:00
commit 9d1b0f8dd9
52 changed files with 6271 additions and 0 deletions

18
server/package.json Normal file
View 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
View 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}`);
});