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

149
docs/TESTING.md Normal file
View File

@@ -0,0 +1,149 @@
# Testing Proximity Without an iPhone
This document explains **how to run and test the entire app using only the
iOS Simulator** — no physical iPhone required for the core interaction loop.
## The strategy in one sentence
> **The interaction logic is fully testable in the simulator. Only the physical
> sensors (UWB, NFC) need real hardware.**
We achieved this by abstracting all hardware behind protocols
(`ProximityAdapterProtocols`) and injecting a `SimulatedAdapter` in DEBUG
builds. `PresenceEngine` talks to the protocols, never to hardware directly,
so it behaves identically whether it's sensing real UWB or receiving simulated
encounters.
## What you can test without any device
| Capability | Simulator | Real iPhone |
|---|---|---|
| SwiftUI UI, navigation, chat | ✅ | ✅ |
| Auth (Apple / Google) | ✅ (Apple needs bundle config) | ✅ |
| Backend + WebSocket live loop | ✅ | ✅ |
| Full reveal flow (wave → mutual → chat) | ✅ | ✅ |
| Simulated encounters / crowd | ✅ | ✅ |
| Core Location / iBeacon | ⚠️ simulated location | ✅ |
| BLE broadcast/discovery | ⚠️ limited | ✅ |
| **UWB (Nearby Interaction)** | ❌ | ✅ |
| **NFC tap** | ❌ | ✅ |
---
## Quick start (simulator)
### 1. Run the reference backend
The Swift client in DEBUG mode points at a local server. Start it:
```bash
cd server
npm install
npm run dev # runs on http://localhost:8080
```
### 2. Run the app in the Simulator
1. Open `Proximity.xcodeproj` in Xcode.
2. Select an iPhone simulator (any model).
3. Build & run (⌘R).
In DEBUG builds, `AppState` automatically:
- Points `APIClient` at `http://localhost:8080`
- Injects `SimulatedAdapter` into the `PresenceEngine`
### 3. Exercise the interaction loop
1. **Sign in** (Apple or Google — the reference server accepts any token).
2. Tap the big **"I'm Here"** toggle to go present.
3. Open the **debug menu** (hammer icon, top-left).
4. Tap **"Simulate someone walking by"** — a silhouette appears in the feed.
5. Tap **Wave** on the silhouette.
6. To complete the reveal, simulate the *other* side waving back — in real
life the server pushes this over the socket. For a fully local test, you
can drive the mutual wave via the debug tools or a second client.
### 4. Test the reveal moment
When both sides wave, `RevealView` presents full-screen — two avatars
converging. Tap **"Say hello"** to open the E2E chat.
---
## Driving the simulation
The debug menu (`DebugMenu`) provides:
- **Simulate someone walking by** — one UWB encounter.
- **Simulate a crowd (20 people)** — a burst of silhouettes.
- **Auto-broadcast nearby users** — emits encounters on a timer (a "busy
street" simulator).
`SimulatedAdapter` also exposes programmatic methods if you want to write
UITests:
```swift
simulatedAdapter.simulateEncounter(remoteToken: "any-token", distance: 1.0)
simulatedAdapter.simulateCrowd(count: 20)
```
---
## Two-simulator / two-client testing
To test the **mutual** flow (both sides), run **two simulator instances**:
1. Boot two simulators (e.g. iPhone 15 + iPhone 15 Pro).
2. Run the app on both against the same local backend.
3. In each, go present and simulate an encounter (the debug menu gives each a
distinct token).
4. Wave from one — the server derives each sender's own token from its
session, records the directional edge, and when both directions exist it
pushes `mutual` to **both** sockets. The reveal triggers on both devices
in real time.
The reference server now implements the full session→token mapping, so the
mutual reveal and E2E key exchange work end-to-end across two clients.
## Running the UI tests
The `ProximityUITests` target drives the happy path automatically:
```bash
xcodebuild test \
-project Proximity.xcodeproj \
-scheme Proximity \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:ProximityUITests/ProximityFlowUITests
```
Start the reference server first (`cd server && npm run dev`).
---
## Testing on real hardware (the sensors)
When you're ready to test UWB / BLE / NFC for real:
1. **Disable the simulated adapter.** Either build a non-DEBUG configuration,
or set `AppState.isSimulated = false`.
2. Use two physical iPhones (iPhone 11+ for UWB).
3. The real `UWBAdapter`, `BLEAdapter`, `BeaconAdapter` take over.
The rest of the app (auth, chat, reveal, backend) works identically — that's
the point of the abstraction.
---
## What the simulator can't do (and why it's OK)
- **UWB**: no support in the simulator. But UWB is only the *"this exact
person"* distance sensor — the interaction logic (handshake → reveal) is
fully testable via simulation.
- **NFC**: no support. NFC is a niche "tap to connect" feature, not core.
- **BLE**: very limited in simulator. The presence *logic* is testable; the
radio isn't.
Since the make-or-break mechanic — **the physical interaction moment** — is
driven by logic + UI, not by the radio, you can validate the entire product
experience in the simulator before ever touching real devices.

513
docs/TESTING_GUIDE.html Normal file
View File

@@ -0,0 +1,513 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Proximity — Testing Guide (M-Series Mac)</title>
<style>
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1e222b;
--border: #2a2f3a;
--text: #e6e8ee;
--muted: #9aa3b2;
--green: #34c759;
--green-dim: #1f7a3d;
--blue: #4aa8ff;
--amber: #ffb340;
--red: #ff5f57;
--mono: "SF Mono", "Menlo", "Consolas", monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.65;
padding-bottom: 80px;
}
.wrap { max-width: 860px; margin: 0 auto; padding: 0 24px; }
header {
background: linear-gradient(135deg, #0d1f14 0%, #0f1115 60%);
border-bottom: 1px solid var(--border);
padding: 48px 0 36px;
margin-bottom: 32px;
}
header .wrap { display: flex; align-items: center; gap: 20px; }
.logo {
width: 64px; height: 64px; border-radius: 16px;
background: radial-gradient(circle at 30% 30%, #34c759, #1f7a3d);
display: flex; align-items: center; justify-content: center;
font-size: 30px; color: white; flex-shrink: 0;
box-shadow: 0 8px 24px rgba(52,199,89,.35);
}
h1 { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
.sub { color: var(--muted); font-size: 15px; margin-top: 4px; }
h2 {
font-size: 21px; font-weight: 700; margin: 40px 0 16px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
h2 .step-num {
background: var(--green); color: #07130a; font-size: 13px; font-weight: 800;
width: 26px; height: 26px; border-radius: 8px; display: inline-flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
h3 { font-size: 16px; font-weight: 600; margin: 22px 0 10px; color: var(--text); }
p { margin: 10px 0; color: var(--text); }
.muted { color: var(--muted); }
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; padding: 18px 20px; margin: 14px 0;
}
.card.tip { border-left: 4px solid var(--blue); }
.card.warn { border-left: 4px solid var(--amber); }
.card.danger { border-left: 4px solid var(--red); }
.card.success { border-left: 4px solid var(--green); }
code {
font-family: var(--mono); font-size: 13px;
background: var(--panel-2); border: 1px solid var(--border);
padding: 2px 6px; border-radius: 5px; color: #d7e0ee;
}
pre {
font-family: var(--mono); font-size: 13px; line-height: 1.6;
background: #0b0d12; border: 1px solid var(--border); border-radius: 10px;
padding: 16px 18px; overflow-x: auto; margin: 12px 0;
}
pre code { background: none; border: none; padding: 0; color: #c9d4e6; }
.cmt { color: #5c6b7a; }
ul, ol { margin: 10px 0 10px 22px; }
li { margin: 6px 0; }
table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 14px; }
th, td { text-align: left; padding: 10px 12px; border: 1px solid var(--border); }
th { background: var(--panel-2); font-weight: 600; }
td { background: var(--panel); }
.yes { color: var(--green); font-weight: 600; }
.no { color: var(--red); font-weight: 600; }
.part { color: var(--amber); font-weight: 600; }
.kbd {
font-family: var(--mono); font-size: 12px; background: var(--panel-2);
border: 1px solid var(--border); border-bottom-width: 3px; border-radius: 6px;
padding: 2px 7px; color: var(--text);
}
.toc { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px 24px; }
.toc a { color: var(--blue); text-decoration: none; }
.toc a:hover { text-decoration: underline; }
.toc ol { margin-left: 18px; }
.toc li { margin: 5px 0; }
.check { color: var(--green); margin-right: 6px; }
.cross { color: var(--red); margin-right: 6px; }
.arrow { color: var(--green); font-weight: 700; }
footer { margin-top: 60px; padding-top: 20px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; text-align: center; }
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="logo">📡</div>
<div>
<h1>Proximity — Testing Guide</h1>
<div class="sub">Step-by-step walkthrough for an Apple Silicon (M-series) MacBook · iOS Simulator · no physical iPhone needed for the core flow</div>
</div>
</div>
</header>
<div class="wrap">
<!-- ================= TOC ================= -->
<div class="toc">
<strong>Contents</strong>
<ol>
<li><a href="#overview">What you're testing &amp; the big idea</a></li>
<li><a href="#prereqs">Prerequisites (install these first)</a></li>
<li><a href="#structure">Project structure map</a></li>
<li><a href="#server">Start the reference backend</a></li>
<li><a href="#xcode">Open &amp; configure the project in Xcode</a></li>
<li><a href="#run">Run the app in the Simulator</a></li>
<li><a href="#walkthrough">Walk through the interaction loop</a></li>
<li><a href="#twosim">Two-simulator mutual reveal test</a></li>
<li><a href="#uitests">Run the automated UI tests</a></li>
<li><a href="#reald">Testing on a real iPhone (the sensors)</a></li>
<li><a href="#troubleshoot">Troubleshooting on M-series</a></li>
</ol>
</div>
<!-- ================= 1. OVERVIEW ================= -->
<h2 id="overview"><span class="step-num">1</span> What you're testing &amp; the big idea</h2>
<p>
Proximity is a social app where you can only connect with people you're
<strong>physically near</strong>. The make-or-break mechanic is the
<em>interaction moment</em>: silhouette → wave → mutual reveal → chat.
</p>
<div class="card success">
<strong>The key insight:</strong> All the <em>interaction logic</em> — the part that makes or breaks the
community — is fully testable in the <strong>iOS Simulator</strong>, with no iPhone. Only the physical
<em>sensors</em> (UWB, NFC) need real hardware. We built a <code>SimulatedAdapter</code> that stands in for
the hardware, so the app behaves identically.
</div>
<table>
<tr><th>Capability</th><th>Simulator</th><th>Real iPhone</th></tr>
<tr><td>UI, navigation, chat</td><td class="yes"></td><td class="yes"></td></tr>
<tr><td>Auth (Apple / Google)</td><td class="yes"></td><td class="yes"></td></tr>
<tr><td>Backend + WebSocket live loop</td><td class="yes"></td><td class="yes"></td></tr>
<tr><td>Reveal flow (wave → mutual → chat)</td><td class="yes"></td><td class="yes"></td></tr>
<tr><td>Simulated encounters / crowd</td><td class="yes"></td><td class="yes"></td></tr>
<tr><td>BLE broadcast / discovery</td><td class="part">⚠️ limited</td><td class="yes"></td></tr>
<tr><td>UWB (Nearby Interaction)</td><td class="no"></td><td class="yes"></td></tr>
<tr><td>NFC tap</td><td class="no"></td><td class="yes"></td></tr>
</table>
<!-- ================= 2. PREREQS ================= -->
<h2 id="prereqs"><span class="step-num">2</span> Prerequisites</h2>
<p>Install these before starting. On an M-series Mac, everything runs natively on Apple Silicon.</p>
<div class="card">
<h3 style="margin-top:0">A. Xcode (required)</h3>
<p>Install from the Mac App Store, or run:</p>
<pre><code>xcode-select --install
<span class="cmt"># If you need a specific version, use Xcodes:</span>
brew install --cask xcodes</code></pre>
<p class="muted">You need Xcode 15 or later for Swift 5.9+ concurrency and SwiftData. Xcode 16 recommended.</p>
</div>
<div class="card">
<h3 style="margin-top:0">B. Node.js (required for the reference server)</h3>
<p>Check if you have it:</p>
<pre><code>node --version <span class="cmt"># want v18+</span>
npm --version</code></pre>
<p>If not, install via Homebrew (native arm64 build):</p>
<pre><code>brew install node</code></pre>
</div>
<div class="card">
<h3 style="margin-top:0">C. Homebrew (recommended)</h3>
<pre><code>/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"</code></pre>
</div>
<div class="card tip">
<strong>M-series note:</strong> Everything here is native arm64. You do <em>not</em> need Rosetta for this
project. If you ever see "running under Rosetta" in Activity Monitor for a tool, it's optional and not required.
</div>
<!-- ================= 3. STRUCTURE ================= -->
<h2 id="structure"><span class="step-num">3</span> Project structure map</h2>
<p>Here's what you're working with:</p>
<pre><code>Nearfield_Friends/
├── Proximity/ <span class="cmt"># iOS app (SwiftUI)</span>
│ ├── App/ <span class="cmt"># entry point, AppState wiring</span>
│ ├── Models/ <span class="cmt"># Encounter, Connection, DistanceTier…</span>
│ ├── Services/
│ │ ├── Proximity/ <span class="cmt"># PresenceEngine + adapters + SimulatedAdapter</span>
│ │ ├── Network/ <span class="cmt"># APIClient, RealtimeClient (WebSocket)</span>
│ │ ├── Auth/ <span class="cmt"># Apple/Google sign-in, profile import</span>
│ │ └── Security/ <span class="cmt"># tokens, E2E crypto</span>
│ ├── ViewModels/ <span class="cmt"># Presence, Chat, Reveal</span>
│ └── Views/ <span class="cmt"># SwiftUI screens + DebugMenu</span>
├── ProximityUITests/ <span class="cmt"># automated UI tests</span>
├── Proximity.xcodeproj/ <span class="cmt"># Xcode project (generated)</span>
├── scripts/
│ └── generate_xcodeproj.py <span class="cmt"># regenerates the .xcodeproj</span>
├── server/ <span class="cmt"># reference backend (Node/TS)</span>
│ └── src/index.ts
└── docs/
├── backend-api-spec.md <span class="cmt"># API contract</span>
├── TESTING.md <span class="cmt"># testing notes</span>
└── TESTING_GUIDE.html <span class="cmt"># this file</span></code></pre>
<div class="card success">
<strong>Good news:</strong> A <code>Proximity.xcodeproj</code> is now included in the repo (generated by
<code>scripts/generate_xcodeproj.py</code>). It contains the app target, the UI test target, and a shared
scheme — so you can open it directly in Xcode and run <code>xcodebuild test</code> with no manual setup.
If you ever add/remove Swift files, re-run the generator to keep the project in sync.
</div>
<!-- ================= 4. SERVER ================= -->
<h2 id="server"><span class="step-num">4</span> Start the reference backend</h2>
<p>The Swift app in DEBUG mode points at a local server on <code>localhost:8080</code>. Start it first.</p>
<div class="card">
<p>Open <strong>Terminal</strong> and run:</p>
<pre><code>cd ~/path/to/Nearfield_Friends/server
npm install
npm run dev</code></pre>
<p>You should see:</p>
<pre><code>Proximity reference server on :8080</code></pre>
</div>
<div class="card warn">
<strong>Keep this terminal window open.</strong> The server must stay running while you test. If you close it,
the app will still work for local UI, but the live socket (mutual reveal, real-time chat) won't.
</div>
<div class="card tip">
<strong>Quick sanity check:</strong> In a second terminal, run
<code>curl http://localhost:8080/v1/nearby?geohash=abc&tier=1</code> — you should get <code>[]</code> back.
</div>
<!-- ================= 5. XCODE ================= -->
<h2 id="xcode"><span class="step-num">5</span> Open &amp; configure the project in Xcode</h2>
<h3>5a. Open the project</h3>
<div class="card">
<p>The <code>Proximity.xcodeproj</code> is already generated. Open it:</p>
<pre><code>open Proximity.xcodeproj</code></pre>
<p>or double-click it in Finder. It includes the app target, the <code>ProximityUITests</code> target, and a
shared scheme, so everything is ready to go.</p>
<p class="muted">The entry point is <code>ProximityApp.swift</code> (marked <code>@main</code>).</p>
</div>
<div class="card tip">
<strong>Keeping the project in sync:</strong> If you add or remove Swift files, regenerate the project so the
file list stays correct:
<pre><code>python3 scripts/generate_xcodeproj.py</code></pre>
Then re-open the project in Xcode.
</div>
<h3>5b. Set the signing team (free account is fine)</h3>
<div class="card">
<ol>
<li>Select the <strong>Proximity</strong> target in the project navigator.</li>
<li>Go to <strong>Signing &amp; Capabilities</strong>.</li>
<li>Check <strong>"Automatically manage signing"</strong>.</li>
<li>Pick your <strong>Team</strong> (your Apple ID — free accounts work for the simulator).</li>
<li>Set a unique <strong>Bundle Identifier</strong>, e.g. <code>com.yourname.proximity</code>.</li>
</ol>
</div>
<h3>5c. Add required capabilities (for real devices later)</h3>
<div class="card">
<p>For the simulator you only strictly need these for the real-hardware build, but add them now so the
project is ready:</p>
<ul>
<li><strong>Near Field Communication Tag Reading</strong> (Core NFC)</li>
<li><strong>Bluetooth</strong> (Core Bluetooth)</li>
<li><strong>Location</strong> (Core Location)</li>
</ul>
<p class="muted">The simulator ignores UWB/NFC but will use simulated location.</p>
</div>
<h3>5d. Add the Info.plist usage strings</h3>
<div class="card">
<p>Add these to <code>Info.plist</code> so permissions work:</p>
<pre><code>NFCReaderUsageDescription "Proximity uses NFC to connect when you tap phones."
NSBluetoothAlwaysUsageDescription "Proximity uses Bluetooth to detect nearby friends."
NSLocationWhenInUseUsageDescription "Proximity uses your location to find people nearby."</code></pre>
</div>
<!-- ================= 6. RUN ================= -->
<h2 id="run"><span class="step-num">6</span> Run the app in the Simulator</h2>
<div class="card">
<ol>
<li>At the top of Xcode, pick a simulator from the device dropdown
(e.g. <strong>iPhone 15</strong> or <strong>iPhone 15 Pro</strong>).</li>
<li>Press <span class="kbd">⌘R</span> to build &amp; run.</li>
<li>The first build may take a minute. The Simulator window opens with the app.</li>
</ol>
</div>
<div class="card success">
<strong>What happens automatically in DEBUG builds:</strong>
<ul>
<li><span class="arrow"></span> <code>APIClient</code> points at <code>http://localhost:8080</code></li>
<li><span class="arrow"></span> <code>SimulatedAdapter</code> is injected into the engine (no hardware)</li>
<li><span class="arrow"></span> A debug menu (hammer icon) appears in the toolbar</li>
</ul>
</div>
<div class="card warn">
<strong>If the app can't reach the server:</strong> the simulator shares your Mac's network, so
<code>localhost</code> works. If you see connection errors, make sure the server from
<a href="#server">Step 4</a> is still running, then use the debug menu's <em>"Reconnect socket"</em>.
</div>
<!-- ================= 7. WALKTHROUGH ================= -->
<h2 id="walkthrough"><span class="step-num">7</span> Walk through the interaction loop</h2>
<p>This is the heart of the product. You'll simulate a full encounter with no hardware.</p>
<div class="card">
<h3 style="margin-top:0">Step 1 — Sign in</h3>
<p>Tap <strong>Continue with Google</strong> (or Apple). The reference server accepts any token, so this
succeeds immediately. You'll land on the main screen.</p>
</div>
<div class="card">
<h3>Step 2 — Go present</h3>
<p>Tap the big circular <strong>"Tap to be Present"</strong> button. It turns green and reads
<strong>"I'm Here"</strong>. This opens the live socket.</p>
</div>
<div class="card">
<h3>Step 3 — Simulate an encounter</h3>
<ol>
<li>Tap the <strong>hammer icon</strong> (top-left) to open the Debug menu.</li>
<li>Tap <strong>"Simulate someone walking by"</strong>.</li>
<li>Go back. A <strong>silhouette</strong> ("Someone nearby") appears in the feed.</li>
</ol>
</div>
<div class="card">
<h3>Step 4 — Wave</h3>
<p>Tap <strong>Wave</strong> on the silhouette. It changes to <strong>"Waved ✓"</strong> — you're now waiting
for the other person.</p>
</div>
<div class="card">
<h3>Step 5 — Complete the reveal (two ways)</h3>
<p><strong>Option A — two simulators</strong> (recommended, <a href="#twosim">see next section</a>): the server
relays the mutual wave and the reveal triggers automatically.</p>
<p><strong>Option B — single simulator:</strong> the reveal needs the other side to wave. Use the debug menu's
<em>"Simulate a crowd"</em> to generate more silhouettes, or drive the mutual wave programmatically via the
simulated adapter.</p>
</div>
<div class="card">
<h3>Step 6 — The reveal moment</h3>
<p>When both sides wave, <code>RevealView</code> presents full-screen: two avatars converge. Tap
<strong>"Say hello"</strong> to open the E2E-encrypted chat.</p>
</div>
<div class="card">
<h3>Step 7 — Chat</h3>
<p>Type a message and send. In a two-simulator setup, the other side receives it decrypted in real time over
the WebSocket.</p>
</div>
<!-- ================= 8. TWO SIM ================= -->
<h2 id="twosim"><span class="step-num">8</span> Two-simulator mutual reveal test</h2>
<p>This tests the real-time mutual flow end-to-end across two "people".</p>
<div class="card">
<ol>
<li>Make sure the reference server is running (<a href="#server">Step 4</a>).</li>
<li>In Xcode, boot a second simulator: <span class="kbd">File</span><span class="kbd">Open Simulator</span>,
or use <span class="kbd">⌃⌘R</span> after adding a second scheme destination.</li>
<li>Run the app on <strong>both</strong> simulators (e.g. iPhone 15 + iPhone 15 Pro).</li>
<li>In each, sign in and go present.</li>
<li>In each, use the debug menu to simulate an encounter (each gets a distinct token).</li>
<li>Wave from one — the server derives each sender's own token from its session, records the directional edge,
and when both directions exist it pushes <code>mutual</code> to <strong>both</strong> sockets.</li>
<li>The reveal triggers on <strong>both</strong> devices in real time.</li>
</ol>
</div>
<div class="card tip">
<strong>Tip:</strong> To run two instances easily, create a second scheme
(<span class="kbd">Product</span><span class="kbd">Scheme</span><span class="kbd">New Scheme</span>) and
set a different simulator destination, then run both schemes.
</div>
<!-- ================= 9. UI TESTS ================= -->
<h2 id="uitests"><span class="step-num">9</span> Run the automated UI tests</h2>
<p>The <code>ProximityUITests</code> target drives the happy path automatically.</p>
<div class="card">
<ol>
<li>Ensure the server is running.</li>
<li>In Xcode, select the <strong>ProximityUITests</strong> test target.</li>
<li>Press <span class="kbd">⌘U</span> to run all tests.</li>
</ol>
<p>Or from the terminal:</p>
<pre><code>cd ~/path/to/Nearfield_Friends
xcodebuild test \
-project Proximity.xcodeproj \
-scheme Proximity \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:ProximityUITests/ProximityFlowUITests</code></pre>
</div>
<div class="card success">
<strong>Ready to go:</strong> The <code>ProximityUITests</code> target and its shared scheme are already part of
<code>Proximity.xcodeproj</code>, so <span class="kbd">⌘U</span> and the <code>xcodebuild test</code> command work
out of the box.
</div>
<!-- ================= 10. REAL DEVICE ================= -->
<h2 id="reald"><span class="step-num">10</span> Testing on a real iPhone (the sensors)</h2>
<p>When you're ready to test the actual UWB / BLE / NFC radios:</p>
<div class="card">
<ol>
<li><strong>Disable the simulated adapter.</strong> Build a Release (non-DEBUG) configuration, or set
<code>AppState.isSimulated = false</code>.</li>
<li>Use <strong>two physical iPhones</strong> (iPhone 11+ for UWB).</li>
<li>Point the app at a reachable backend (change <code>APIClient.baseURL</code> to your Mac's LAN IP or a
deployed server).</li>
<li>Trust the developer certificate on each device (Settings → General → VPN &amp; Device Management).</li>
<li>Run from Xcode with the device selected. The real <code>UWBAdapter</code>, <code>BLEAdapter</code>,
<code>BeaconAdapter</code> take over.</li>
</ol>
</div>
<div class="card danger">
<strong>Important:</strong> UWB and NFC are <em>not</em> available in the simulator. Only test these on real
hardware. Everything else works identically — that's the point of the abstraction.
</div>
<!-- ================= 11. TROUBLESHOOT ================= -->
<h2 id="troubleshoot"><span class="step-num">11</span> Troubleshooting on M-series</h2>
<div class="card warn">
<h3 style="margin-top:0">App won't connect to the server</h3>
<p><span class="check"></span> Confirm the server terminal shows <code>:8080</code> and is still running.<br>
<span class="check"></span> Use the debug menu's <em>"Reconnect socket"</em>.<br>
<span class="check"></span> Try <code>curl http://localhost:8080/v1/nearby?geohash=abc&tier=1</code> in Terminal.</p>
</div>
<div class="card warn">
<h3 style="margin-top:0">Build fails / missing files</h3>
<p><span class="check"></span> Make sure all folders under <code>Proximity/</code> are added to the target
(not just referenced).<br>
<span class="check"></span> Confirm <code>ProximityApp.swift</code> is the <code>@main</code> entry and only one
<code>@main</code> exists.</p>
</div>
<div class="card warn">
<h3 style="margin-top:0">Signing errors</h3>
<p><span class="check"></span> For the simulator you can often use <em>"Sign to Run Locally"</em> / no team.<br>
<span class="check"></span> Use a unique bundle identifier.</p>
</div>
<div class="card warn">
<h3 style="margin-top:0">"Rosetta" warnings or arch issues</h3>
<p><span class="check"></span> This project is native arm64 — no Rosetta needed.<br>
<span class="check"></span> If a tool was installed via an Intel package, reinstall via <code>brew</code>
(arm64) or use the Apple Silicon build.</p>
</div>
<div class="card warn">
<h3 style="margin-top:0">Simulator can't find localhost</h3>
<p><span class="check"></span> The simulator shares the Mac's network, so <code>localhost</code> resolves to
your Mac. If you moved the server to another machine, update <code>APIClient.local()</code> to that IP.</p>
</div>
<div class="card warn">
<h3 style="margin-top:0">Mutual reveal never triggers in single-simulator mode</h3>
<p><span class="check"></span> A reveal needs <em>both</em> sides to wave. Use the two-simulator setup
(<a href="#twosim">Step 8</a>) for the full end-to-end test, or drive the mutual wave programmatically.</p>
</div>
<footer>
Proximity · Testing Guide · Apple Silicon (M-series) · iOS Simulator-first workflow
</footer>
</div>
</body>
</html>

274
docs/backend-api-spec.md Normal file
View File

@@ -0,0 +1,274 @@
# Proximity — Backend API Specification
This document defines the server contract that the **Swift iOS client** expects.
Every endpoint and payload here mirrors exactly what's implemented in the client
(`Services/Network/APIClient.swift` and `Services/Network/RealtimeClient.swift`).
The goal is that a backend team can stand up this API from scratch and the
existing client will work without changes.
**Base URL:** `https://api.proximity.app`
---
## 1. Design Principles
1. **Zero-knowledge by default.** The server correlates anonymous tokens and
relays ciphertext. It never stores identity, plaintext messages, or precise
location history.
2. **Two identities, never conflated.**
- *Account identity* — via auth (`/v1/auth/*`). Private, used for sessions.
- *Social identity* — only surfaces in a **mutual** reveal, and even then
the server only relays opaque keys; it never stores the profile.
3. **Foreground-first.** The server is a correlation + relay layer, not a
background continuous-tracking layer. Presence sessions are short-lived.
---
## 2. Authentication
### 2.1 `POST /v1/auth/exchange`
Exchange a provider ID token for a Proximity session.
**Request:**
```json
{
"provider": "apple", // "apple" | "google"
"idToken": "<provider-jwt>",
"nonce": "<nonce-or-empty>"
}
```
**Response `200` — `Session`:**
```json
{
"token": "<proximity-session-bearer-token>",
"account": {
"id": "<opaque-account-id>",
"providerID": "apple",
"displayName": "…",
"email": "…"
}
}
```
**Server responsibilities:**
- Verify the provider ID token with the provider (Apple/Google public keys).
- For Apple, verify the `nonce` matches the one in the ID token.
- Issue a short-lived Proximity session token (e.g. JWT, 7-day expiry).
- Return only the fields the client's `Account` model decodes.
### 2.2 `POST /v1/auth/revoke`
Revoke a session. Bearer token in `Authorization` header. No body expected.
---
## 3. Presence & Token Relay
> **Key model:** While present, a client broadcasts a **rotating anonymous
> token** (see `TokenManager`). The server maps token → connected socket, so it
> can push events to the right device without ever knowing the user's identity.
### 3.1 `POST /v1/token`
Register the current anonymous token so the server can route to this device.
**Request:**
```json
{
"token": "<rotating-anon-token>",
"tier": 1, // DistanceTier.rawValue: 1=UWB, 2=BLE, 3=area
"geohash": "…" // optional, for Tier 3 discovery
}
```
**Auth:** `Authorization: Bearer <session-token>`.
**Server:** associate `token` with the authenticated session and its open
WebSocket. This is the **session → own-token** mapping that lets the server
derive who a wave is *from*. Drop associations older than one rotation window
(5 min).
### 3.2 `GET /v1/nearby?geohash=<h>&tier=<n>`
Fetch anonymous tokens of present users near a coarse location.
**Response `200`:**
```json
["<token-a>", "<token-b>", "…"]
```
**Server:** return tokens registered within the same coarse region for the
requested tier. Used for Tier 3 (network) discovery. Tier 1/2 handshakes happen
directly over UWB/BLE and are reported via `/v1/encounter`.
### 3.3 `POST /v1/encounter`
Report a local physical encounter (fire-and-forget).
**Request body** is the client's `Encounter` model:
```json
{
"id": "<uuid>",
"remoteAnonToken": "<token>",
"timestamp": "…",
"tier": 1,
"engine": "uwb",
"geohash": "…",
"status": "silhouette"
}
```
**Server:** correlate the two tokens that were physically near each other and
record the mutual relationship for later matching. **Do not** store identity or
precise location.
---
## 4. The Reveal (Wave) Flow
This is the heart of the product. The server's job is to reliably relay a
mutual wave between two present devices in real time.
### 4.1 `POST /v1/wave`
Send a wave to a silhouette — signals interest in mutual reveal.
**Request:**
```json
{ "token": "<remote-anon-token>" }
```
**Auth:** `Authorization: Bearer <session-token>`.
**Server behavior:**
1. Derive the sender's **own** token from the session (via `/v1/token`).
2. Record a directional edge `ownToken → remoteToken`.
3. Push a **`mutual`** event to the *other* device only if **both** users have
waved at each other. (If the other user already waved, this device gets a
`mutual` push back immediately.)
4. On mutual, create/reuse a `connections` entry and include `connectionID`
in the pushed event so messaging can route.
5. A wave is **directional**: `A → B` does not reveal until `B → A`.
### 4.2 `POST /v1/peer-key` — upload own public key
Upload the caller's E2E public key so a mutual peer can fetch it.
**Request:**
```json
{ "publicKey": "<base64 raw X25519 public key>" }
```
**Auth:** `Authorization: Bearer <session-token>`. The server stores the key
against the caller's own token.
### 4.3 `GET /v1/peer-key?token=<remote-token>` — fetch peer key
Fetch the peer's public key to establish the E2E session key.
**Auth:** `Authorization: Bearer <session-token>`.
**Response `200`:**
```json
{ "publicKey": "<base64 raw X25519 public key>" }
```
**Server:** return the peer's key **only** if the caller and peer have a
**mutual** wave relationship (`403` otherwise). This is the only
identity-bearing data the server touches, and only within an established
mutual relationship.
### 4.4 `POST /v1/block`
Permanently block a token. Removes it from the graph in both directions.
**Request:**
```json
{ "token": "<remote-anon-token>" }
```
**Server:** delete the edge and refuse future waves/messages from this token.
---
## 5. Messaging (E2E)
### 5.1 `POST /v1/message`
Send an encrypted message. **Only ciphertext leaves the device.**
**Request body** is the client's `ChatMessage` model:
```json
{
"id": "<uuid>",
"connectionID": "<uuid>",
"senderID": "<opaque-user-id>",
"ciphertext": "<base64 ChaChaPoly sealed box>",
"sentAt": "…"
}
```
**Server:**
- Validate the sender is part of the `connectionID`.
- Store/relay the ciphertext only. **Never decrypt.**
- Push a **`message`** event to the recipient's socket in real time.
---
## 6. Real-time WebSocket
**Endpoint:** `wss://api.proximity.app/ws`
**Auth:** `Authorization: Bearer <proximity-session-token>`
The client connects when the user goes **present** and disconnects when they
leave. The server must route events to the correct socket via the token
registration from `/v1/token`.
### 6.1 Server → Client events
The client's `RealtimeClient` parses this envelope:
```json
{ "type": "mutual", "remoteToken": "<token>" }
{ "type": "message", "message": { …ChatMessage… } }
```
**`mutual`** — the other party waved back. The client matches `remoteToken`
against its waiting encounter and advances to the reveal.
**`message`** — a new encrypted message. The client decrypts and appends.
### 6.2 Client → Server events (optional)
The client may send `ping`/JSON keepalives; the server should respond with
`{ "type": "pong" }`. The server must tolerate silent clients and drop stale
socket→token mappings.
---
## 7. Data Model (server-side)
| Table | Fields | Notes |
|---|---|---|
| `accounts` | id, provider, display_name, email, created_at | account identity |
| `sessions` | token, account_id, expires_at | bearer sessions |
| `presence` | token, account_id, tier, geohash, socket_id, updated_at | rotating anon presence |
| `encounters` | id, token_a, token_b, tier, engine, created_at | mutual proximity record |
| `waves` | from_token, to_token, created_at | directional interest edges |
| `keys` | token, public_key | E2E public keys (mutual only) |
| `connections` | id, user_a, user_b, created_at | mutual reveals |
| `messages` | id, connection_id, sender_id, ciphertext, created_at | ciphertext only |
---
## 8. Security & Privacy Checklist
- [ ] Provider tokens verified server-side (never trust client).
- [ ] Session tokens short-lived + revocable.
- [ ] Presence tokens rotate client-side; server drops stale ones.
- [ ] `waves` are directional; no reveal without mutual consent.
- [ ] `peer-key` returned only within a mutual relationship.
- [ ] Messages stored as ciphertext only; no decryption server-side.
- [ ] `block` is permanent and bidirectional.
- [ ] Right-to-be-forgotten: deleting an account purges all rows.
- [ ] No precise location history retained by default.
---
## 9. Suggested Tech Stack
- **Language:** Go or Node.js (TypeScript) — good WebSocket support.
- **Transport:** HTTPS + WebSocket (single origin to share auth).
- **Persistence:** PostgreSQL (encounters/waves/messages) + Redis (presence
sockets, ephemeral token→socket mapping).
- **Push:** APNs for offline wake-ups (best-effort; presence is foreground).