` + https link
+ - Share via Share API
+ - Redeem flow
+
+6. **Fart Detail** (`app/fart-detail.tsx`)
+ - Deadpan "X farted." + map pin if location attached
+ - One-tap fart back
+ - Context-based messaging note
+
+7. **Settings** (`app/settings.tsx`)
+ - Remove Ads IAP ($1.99 suggestion) + Restore
+ - Notification sound toggle (future)
+ - Phone discovery toggle (real API)
+ - Invite creation
+ - Privacy note
+ - Sign out
+
+## Push Mechanics
+
+- Client: `expo-notifications` → `getExpoPushTokenAsync()` → `ExponentPushToken[...]`
+- Server: `POST https://exp.host/--/api/v2/push/send` with `{to, title=senderName, body="I farted.", sound="fart.caf", data:{type:"fart", messageId, senderId, lat?, lng?, sentAt}}`
+- Custom sound: iOS bundles <30s `fart.caf` referenced in payload, Android defines channel `farts` with `fart.mp3`
+- Android FCM credentials: `google-services.json` secret, injected at EAS build time, never committed
+- Notification handler: `shouldShowAlert=true, shouldPlaySound=true` — notification IS message
+
+## Deep Links
+
+- `ifarted://invite/` — Expo linking
+- `https://ifarted.app/invite/` — web fallback, associated domains
+- Notification taps → `fart-detail` with params
+
+## Ads + IAP
+
+- **Free**: AdMob banner on home (non-personalized first, no ATT)
+- **Paid**: one-time non-consumable `remove_ads` — StoreKit / Play Billing
+- Single gated ` ` routed through `isAdFree` flag (Zustand)
+- Entitlement source of truth = store state (RevenueCat favored for cross-platform restore)
+- AdMob App IDs in `app.json` (replace placeholder `ca-app-pub-...`)
+
+## Permissions / Privacy
+
+- iOS `NSLocationWhenInUseUsageDescription` via app.json
+- Android `ACCESS_FINE_LOCATION`, `READ_CONTACTS`
+- Push permission rationale plain language
+- Store privacy nutrition labels: location per-message, contacts opt-in, no history
+- No message history/inbox/feed — ephemeral by design
+
+## Config
+
+`app.json` is source of truth:
+- `scheme: ifarted`
+- `ios.bundleIdentifier: com.ifarted.app`
+- `android.package: com.ifarted.app`
+- `extra.eas.projectId` — Expo project ID for push
+- `extra.apiUrl` — relay server URL (default `https://api.ifarted.app`, local `http://localhost:3000`)
+
+Secrets via EAS env vars / `.env` (git-ignored):
+- `IOS_GOOGLE_MAPS_API_KEY`
+- `ANDROID_GOOGLE_MAPS_API_KEY`
+- `google-services.json`
+
+## Branding TODO (from activeContext)
+
+- Audio asset: fart sound <30s, on-brand not too gross for review
+- Remove Ads price/library: $1.99 suggestion, expo-iap vs RevenueCat
+- Ad placement: banner default, interstitial after send? (UX/review cost)
+- Final store name (working: iFarted), icon, screenshots, store copy, tone pass
+- Server deployment target + invite deep-link domain once branding set
+
+## EAS Build
+
+```bash
+npm install -g eas-cli
+eas login
+eas build:configure
+eas build --profile development --platform all
+eas build --profile preview --platform all
+eas submit --platform ios
+eas submit --platform android
+```
+
+iOS builds require Apple Developer Program ($99/yr), cannot build from Linux without EAS cloud.
+
+## Testing on Real Devices Early
+
+Push, sound, maps, location are device-dependent — test early.
+
+## Context-Based Messaging (App Review explanation)
+
+> "We like to call it context-based messaging. You understand by the context what is being said." — Or Arbel (Yo creator)
+
+One phrase, meaning from context (who, when, where). Apple once rejected Yo for being "too simple" — have this explanation ready.
+
+## Monetization (Yo died for lack of revenue)
+
+Ads + IAP from day one. No business model killed Yo in 2016.
diff --git a/apps/mobile/STORE_CHECKLIST.md b/apps/mobile/STORE_CHECKLIST.md
new file mode 100644
index 00000000..2ca03338
--- /dev/null
+++ b/apps/mobile/STORE_CHECKLIST.md
@@ -0,0 +1,95 @@
+# Store Checklist — iFarted
+
+## Branding (open decisions)
+- [ ] Final store name: working `iFarted` — check trademark, App Store search, domain
+- [ ] Icon: simple, not too gross, recognizable at small size (1024x1024 iOS, 512x512 Android)
+- [ ] Screenshots: 6.5" and 5.5" iOS, phone + tablet Android, show home list + fart detail + map + settings
+- [ ] Store copy: tone dry/wry, never gross. Explain context-based messaging.
+- [ ] Preview video (optional): 15-30s, show tap-to-fart + notification + map pin + fart back
+- [ ] In-app copy tone pass: dry/wry, consistent
+
+## Audio Asset (open)
+- [ ] Fart sound: <30s for iOS, on-brand not too loud/gross for reviewers
+- [ ] Files: `fart.caf` (iOS, linear PCM or IMA4) + `fart.mp3` (Android)
+- [ ] Place in `assets/sounds/` + `android/app/src/main/res/raw/` + iOS bundle
+- [ ] Reference in `app.json` expo-notifications.sounds + payload `sound: "fart.caf"`
+- [ ] Test on real devices — sound must play when app in background/killed
+
+## App Store Connect (iOS)
+- [ ] Apple Developer Program $99/yr — enroll
+- [ ] App ID: `com.ifarted.app` — create
+- [ ] APNs key — generate, upload to Expo credentials
+- [ ] EAS credentials: `eas credentials` — configure
+- [ ] App Store Connect record — create app
+- [ ] Privacy nutrition labels:
+ - Location: per-message opt-in, only to chosen recipient, not logged in analytics
+ - Contacts: opt-in only, hashed, only discovery-enabled matches
+ - No history, no feed, no tracking (non-personalized ads first → no ATT)
+- [ ] Purpose strings: `NSLocationWhenInUseUsageDescription`, `NSContactsUsageDescription` — already in app.json
+- [ ] IAP: `remove_ads` non-consumable, $1.99 suggestion, description, review screenshot
+- [ ] AdMob: iOS App ID + ad unit ID — replace placeholder in app.json
+- [ ] Build: `eas build --profile production --platform ios` → submit via `eas submit`
+- [ ] App Review explanation: context-based messaging quote, Yo! pattern, pure comedic utility, no spam (rate limits + block), user-initiated targeted push
+
+## Google Play Console (Android)
+- [ ] Play Console $25 one-time — enroll
+- [ ] App: `com.ifarted.app` — create
+- [ ] Firebase project (free) — create, enable FCM, download `google-services.json` (secret, inject via EAS env, never commit)
+- [ ] EAS credentials: Android keystore
+- [ ] Data safety form:
+ - Location: per-message opt-in, only to chosen recipient
+ - Contacts: opt-in, hashed
+ - No history
+- [ ] IAP: `remove_ads` non-consumable, $1.99, managed product
+- [ ] AdMob: Android App ID + ad unit ID
+- [ ] Build: `eas build --profile production --platform android` → `eas submit`
+- [ ] Content rating, target audience, etc.
+
+## Expo / EAS
+- [ ] Expo account — create
+- [ ] Project ID in `app.json` extra.eas.projectId — replace `00000000-...`
+- [ ] `eas.json` — already has development/preview/production
+- [ ] Secrets: `IOS_GOOGLE_MAPS_API_KEY`, `ANDROID_GOOGLE_MAPS_API_KEY`, `google-services.json` via `eas secret:create` or `EAS env vars`
+- [ ] Development builds for push testing: `eas build --profile development --platform all` → install on 2 devices
+
+## Server Deployment (open decision)
+- [ ] Choose: cheap VPS (Hetzner $5/mo) / Fly.io / Railway
+- [ ] Fly.io example: `fly launch`, `fly secrets set PORT=3000`, `fly deploy`, volume for `ifarted.db`
+- [ ] Domain: `api.ifarted.app` + `ifarted.app/invite/*` deep link domain (once branding set)
+- [ ] HTTPS + backup cron for SQLite
+- [ ] Monitoring: rate limit abuse, block, error logs
+- [ ] Update mobile `extra.apiUrl` from `http://localhost:3000` to `https://api.ifarted.app`
+
+## Permissions / Privacy Polish
+- [ ] iOS privacy manifest (`PrivacyInfo.xcprivacy`) — location, contacts usage
+- [ ] Android runtime permissions: location lazily via `expo-location` only when toggle on, contacts only when scanning
+- [ ] Push permission rationale: plain language before request
+- [ ] Settings: phone-discovery toggle (done), notification sound toggle (future), account (username, sign out), privacy note
+
+## Monetization (open)
+- [ ] Ad placement: banner on home (default) — confirm no interstitial before sending (blocks joke, review risk)
+- [ ] Remove Ads price: $1.99 suggestion — research competitors
+- [ ] Library: expo-iap vs RevenueCat — RevenueCat favored (entitlements + restore)
+- [ ] Single gated ` ` via `isAdFree` flag — done
+- [ ] Test IAP in sandbox / internal testing
+
+## Testing
+- [ ] Real devices early: push, sound, maps, location device-dependent
+- [ ] Two dev-build devices, Expo Push API, custom sound, location payload — end-to-end
+- [ ] Empty network kills app → first-run add-a-friend flow most important screen
+- [ ] Harassment vector: rate limits + block list (server), recipient list explicit (only people you added)
+- [ ] Yo hack lessons: auth on every endpoint, never leak PII, unguessable tokens
+
+## Legal / Policy
+- [ ] Terms + Privacy Policy URL — needed for store listings
+- [ ] No P2P push — always backend → Expo Push → APNs/FCM
+- [ ] Push user-initiated and targeted at known recipient (anti-spam + store policy)
+- [ ] Remove Ads must be store-billed IAP — out-of-band payment is rejection grounds
+- [ ] Context-based messaging framing for App Review (Apple rejected Yo for "too simple")
+
+## Alpha → Store
+- [ ] Alpha on real devices both platforms
+- [ ] TestFlight internal + external
+- [ ] Play internal testing track
+- [ ] Store assets + compliance review
+- [ ] Submit
diff --git a/apps/mobile/android/app/src/main/res/raw/fart.mp3 b/apps/mobile/android/app/src/main/res/raw/fart.mp3
new file mode 100644
index 00000000..81d70ecb
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/raw/fart.mp3 differ
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
new file mode 100644
index 00000000..99eaa247
--- /dev/null
+++ b/apps/mobile/app.json
@@ -0,0 +1,90 @@
+{
+ "expo": {
+ "name": "iFarted",
+ "slug": "ifarted",
+ "version": "0.1.0",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "scheme": "ifarted",
+ "userInterfaceStyle": "light",
+ "splash": {
+ "image": "./assets/splash.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ },
+ "assetBundlePatterns": ["**/*"],
+ "ios": {
+ "supportsTablet": false,
+ "bundleIdentifier": "com.ifarted.app",
+ "infoPlist": {
+ "NSLocationWhenInUseUsageDescription": "Attach your current location to a fart so your friend can see where you farted.",
+ "NSContactsUsageDescription": "Find friends who already use iFarted from your contacts (opt-in only).",
+ "UIBackgroundModes": ["remote-notification"]
+ },
+ "config": {
+ "googleMobileAdsAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy"
+ }
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/adaptive-icon.png",
+ "backgroundColor": "#ffffff"
+ },
+ "package": "com.ifarted.app",
+ "permissions": [
+ "android.permission.ACCESS_COARSE_LOCATION",
+ "android.permission.ACCESS_FINE_LOCATION",
+ "android.permission.READ_CONTACTS"
+ ],
+ "config": {
+ "googleMobileAdsAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy"
+ },
+ "googleServicesFile": "./google-services.json"
+ },
+ "web": {
+ "bundler": "metro",
+ "output": "static",
+ "favicon": "./assets/favicon.png"
+ },
+ "plugins": [
+ "expo-router",
+ [
+ "expo-notifications",
+ {
+ "icon": "./assets/notification-icon.png",
+ "color": "#ffffff",
+ "sounds": ["./assets/sounds/fart.caf", "./assets/sounds/fart.mp3"]
+ }
+ ],
+ [
+ "expo-location",
+ {
+ "locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location to attach to farts."
+ }
+ ],
+ [
+ "react-native-google-mobile-ads",
+ {
+ "androidAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy",
+ "iosAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy"
+ }
+ ],
+ [
+ "react-native-maps",
+ {
+ "iosGoogleMapsApiKey": "${IOS_GOOGLE_MAPS_API_KEY}",
+ "androidGoogleMapsApiKey": "${ANDROID_GOOGLE_MAPS_API_KEY}"
+ }
+ ]
+ ],
+ "experiments": {
+ "typedRoutes": true
+ },
+ "extra": {
+ "eas": {
+ "projectId": "00000000-0000-0000-0000-000000000000"
+ },
+ "apiUrl": "https://api.ifarted.app"
+ }
+ }
+}
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
new file mode 100644
index 00000000..369a1ef9
--- /dev/null
+++ b/apps/mobile/app/_layout.tsx
@@ -0,0 +1,34 @@
+import { Stack } from "expo-router";
+import * as Notifications from "expo-notifications";
+import { useEffect } from "react";
+
+// Notification handler — notification IS the message (ephemeral)
+Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldShowAlert: true,
+ shouldPlaySound: true,
+ shouldSetBadge: false,
+ }),
+});
+
+export default function RootLayout() {
+ useEffect(() => {
+ // Request permissions early but explain why (context-based messaging)
+ (async () => {
+ const { status } = await Notifications.requestPermissionsAsync();
+ console.log("[notif] permission", status);
+ })();
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/app/contacts.tsx b/apps/mobile/app/contacts.tsx
new file mode 100644
index 00000000..44f8eecc
--- /dev/null
+++ b/apps/mobile/app/contacts.tsx
@@ -0,0 +1,106 @@
+import { View, Text, FlatList, TouchableOpacity, Alert, ActivityIndicator } from "react-native";
+import { useState } from "react";
+import { useAuth } from "../src/store/useAuth";
+import { Api } from "../src/lib/api";
+import { requestContactsPermission, getPhoneNumbers } from "../src/lib/contacts";
+import { useFriends } from "../src/store/useFriends";
+
+export default function ContactsScreen() {
+ const { apiKey } = useAuth();
+ const [loading, setLoading] = useState(false);
+ const [matches, setMatches] = useState<{ id: string; username: string; displayName?: string }[]>([]);
+ const { addFriend } = useFriends();
+
+ const findFriends = async () => {
+ if (!apiKey) {
+ Alert.alert("Not registered");
+ return;
+ }
+ setLoading(true);
+ try {
+ const granted = await requestContactsPermission();
+ if (!granted) {
+ Alert.alert("Permission needed", "Contacts permission is required to find friends (opt-in only).");
+ setLoading(false);
+ return;
+ }
+
+ const numbers = await getPhoneNumbers();
+ if (numbers.length === 0) {
+ Alert.alert("No numbers", "No phone numbers found in contacts.");
+ setLoading(false);
+ return;
+ }
+
+ console.log(`[contacts] found ${numbers.length} numbers, checking with server...`);
+ const res = await Api.contacts(apiKey, numbers);
+ setMatches(res.matches);
+ Alert.alert("Done", `Found ${res.matches.length} friends from contacts who enabled discovery.`);
+ } catch (e: any) {
+ Alert.alert("Failed", e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const add = async (userId: string, username: string) => {
+ if (!apiKey) return;
+ try {
+ await Api.addFriend(apiKey, userId);
+ addFriend({
+ id: userId,
+ username,
+ displayName: username,
+ addedVia: "contacts",
+ addedAt: new Date().toISOString(),
+ });
+ Alert.alert("Added", `@${username} added via contacts`);
+ } catch (e: any) {
+ Alert.alert("Failed", e.message);
+ }
+ };
+
+ return (
+
+ Find friends from contacts
+
+ Opt-in only. Phone numbers are hashed server-side, never stored raw. Only reveals matches who enabled phone discovery.
+
+
+
+ {loading ? "Scanning..." : "Scan contacts"}
+
+
+ {loading && }
+
+ i.id}
+ style={{ marginTop: 16 }}
+ renderItem={({ item }) => (
+
+
+ {item.displayName || item.username}
+ @{item.username} · via contacts
+
+ add(item.id, item.username)} style={{ backgroundColor: "#000", paddingHorizontal: 12, paddingVertical: 8, borderRadius: 20 }}>
+ Add
+
+
+ )}
+ ListEmptyComponent={!loading ? No matches yet. Make sure friends enabled phone discovery in Settings. : null}
+ />
+
+
+ Privacy note
+
+ We never upload your entire address book raw. Numbers are normalized and hashed. Server only returns users who explicitly enabled discovery. You can toggle this in Settings.
+
+
+
+ );
+}
diff --git a/apps/mobile/app/fart-detail.tsx b/apps/mobile/app/fart-detail.tsx
new file mode 100644
index 00000000..a69d80bf
--- /dev/null
+++ b/apps/mobile/app/fart-detail.tsx
@@ -0,0 +1,58 @@
+import { View, Text, TouchableOpacity, Alert } from "react-native";
+import { useLocalSearchParams } from "expo-router";
+import MapView, { Marker } from "react-native-maps";
+import { useAuth } from "../src/store/useAuth";
+import { Api } from "../src/lib/api";
+
+export default function FartDetail() {
+ const params = useLocalSearchParams() as { senderName?: string; senderId?: string; lat?: string; lng?: string };
+ const { apiKey } = useAuth();
+ const lat = params.lat ? parseFloat(params.lat) : undefined;
+ const lng = params.lng ? parseFloat(params.lng) : undefined;
+ const hasLocation = lat !== undefined && lng !== undefined;
+
+ const fartBack = async () => {
+ if (!apiKey || !params.senderId) {
+ Alert.alert("Can't fart back", "Missing sender");
+ return;
+ }
+ try {
+ await Api.sendFart(apiKey, { recipientId: params.senderId });
+ Alert.alert("Fart back delivered 🫢");
+ } catch (e: any) {
+ Alert.alert("Failed", e.message);
+ }
+ };
+
+ return (
+
+
+ 💨
+ {params.senderName || "Someone"} farted.
+ {hasLocation ? "With location attached" : "No location"}
+
+ Context-based messaging: you understand by the context what is being said.
+
+
+
+ {hasLocation ? (
+
+
+
+ ) : (
+
+ No location attached. The joke is the notification itself.
+
+ )}
+
+
+
+ Fart back 💨
+
+
+
+ );
+}
diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx
new file mode 100644
index 00000000..d5f9c3d2
--- /dev/null
+++ b/apps/mobile/app/index.tsx
@@ -0,0 +1,177 @@
+import { View, Text, FlatList, TouchableOpacity, Switch, Alert, RefreshControl } from "react-native";
+import { useState, useEffect, useCallback } from "react";
+import { Link } from "expo-router";
+import * as Location from "expo-location";
+import { AdBanner } from "../src/components/AdBanner";
+import { useAuth } from "../src/store/useAuth";
+import { useFriends } from "../src/store/useFriends";
+import { Api } from "../src/lib/api";
+import * as Notifications from "expo-notifications";
+import { getExpoPushToken, ensureNotificationChannel, addNotificationListeners } from "../src/lib/notifications";
+import { router } from "expo-router";
+
+export default function Home() {
+ const { username, apiKey } = useAuth();
+ const { friends, setFriends } = useFriends();
+ const [attachLocation, setAttachLocation] = useState(false);
+ const [sendingTo, setSendingTo] = useState(null);
+ const [refreshing, setRefreshing] = useState(false);
+
+ const loadFriends = useCallback(async () => {
+ if (!apiKey) return;
+ try {
+ const res = await Api.friends(apiKey);
+ setFriends(res.friends);
+ } catch (e) {
+ console.warn("[home] loadFriends failed", e);
+ }
+ }, [apiKey, setFriends]);
+
+ useEffect(() => {
+ loadFriends();
+ }, [loadFriends]);
+
+ useEffect(() => {
+ // Setup push
+ (async () => {
+ await ensureNotificationChannel();
+ if (apiKey) {
+ const token = await getExpoPushToken();
+ if (token) {
+ try {
+ await Api.registerToken(apiKey, { expoPushToken: token as any, platform: "ios" as any });
+ console.log("[home] push token registered");
+ } catch (e) {
+ console.warn("[home] token register failed", e);
+ }
+ }
+ }
+ })();
+
+ const cleanup = addNotificationListeners({
+ onResponse: (response) => {
+ const data = response.notification.request.content.data as any;
+ if (data?.type === "fart") {
+ router.push({
+ pathname: "/fart-detail",
+ params: {
+ senderName: data.senderName,
+ senderId: data.senderId,
+ lat: data.lat?.toString(),
+ lng: data.lng?.toString(),
+ },
+ });
+ }
+ },
+ });
+
+ return cleanup;
+ }, [apiKey]);
+
+ const onRefresh = useCallback(async () => {
+ setRefreshing(true);
+ await loadFriends();
+ setRefreshing(false);
+ }, [loadFriends]);
+
+ const sendFart = async (recipientId: string) => {
+ if (!apiKey) {
+ Alert.alert("Not registered", "Go to onboarding first");
+ return;
+ }
+ setSendingTo(recipientId);
+ try {
+ let lat, lng;
+ if (attachLocation) {
+ const { status } = await Location.requestForegroundPermissionsAsync();
+ if (status !== "granted") {
+ Alert.alert("Location permission needed to attach location");
+ } else {
+ const loc = await Location.getCurrentPositionAsync({});
+ lat = loc.coords.latitude;
+ lng = loc.coords.longitude;
+ }
+ }
+
+ const res = await Api.sendFart(apiKey, { recipientId, lat, lng });
+ Alert.alert("Fart delivered 🫢", `Message ${res.messageId}`);
+ // Refresh to update lastFartAt ordering
+ loadFriends();
+ } catch (e: any) {
+ Alert.alert("Failed", e.message);
+ } finally {
+ setSendingTo(null);
+ }
+ };
+
+ return (
+
+
+ iFarted
+
+ {username ? `@${username}` : "No username — onboard first"} · Context-based messaging
+
+
+ Attach location
+
+
+
+
+
+ Onboarding
+
+
+
+
+ Search @
+
+
+
+
+ Contacts
+
+
+
+
+ Invite
+
+
+
+
+ Settings
+
+
+
+
+
+ i.id}
+ refreshControl={ }
+ renderItem={({ item }) => (
+ sendFart(item.id)}
+ disabled={sendingTo === item.id}
+ style={{ padding: 16, borderBottomWidth: 1, borderColor: "#f0f0f0", flexDirection: "row", justifyContent: "space-between" }}
+ >
+
+ {item.displayName || item.username}
+ @{item.username} · via {item.addedVia} {item.lastFartAt ? `· last fart ${new Date(item.lastFartAt).toLocaleTimeString()}` : ""}
+
+
+ {sendingTo === item.id ? "..." : "💨 Fart"}
+
+
+ )}
+ ListEmptyComponent={
+
+ No friends yet — add via username search, contacts, or invite link.
+ Home = recipient list ordered by most-recently active (Yo-style). No inbox/history — notification IS message.
+
+ }
+ />
+
+
+
+ );
+}
diff --git a/apps/mobile/app/invite.tsx b/apps/mobile/app/invite.tsx
new file mode 100644
index 00000000..e51c198d
--- /dev/null
+++ b/apps/mobile/app/invite.tsx
@@ -0,0 +1,114 @@
+import { View, Text, TouchableOpacity, Alert, Share, TextInput } from "react-native";
+import { useState } from "react";
+import { useAuth } from "../src/store/useAuth";
+import { Api } from "../src/lib/api";
+
+export default function InviteScreen() {
+ const { apiKey } = useAuth();
+ const [code, setCode] = useState(null);
+ const [deepLink, setDeepLink] = useState(null);
+ const [inviteLink, setInviteLink] = useState(null);
+ const [manualCode, setManualCode] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const createInvite = async () => {
+ if (!apiKey) {
+ Alert.alert("Not registered");
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await Api.createInvite(apiKey);
+ setCode(res.code);
+ setDeepLink(res.deepLink);
+ setInviteLink(res.inviteLink);
+ } catch (e: any) {
+ Alert.alert("Failed", e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const shareInvite = async () => {
+ if (!inviteLink) return;
+ try {
+ await Share.share({
+ message: `Send me a fart on iFarted! 💨 Use code ${code} or open ${inviteLink}`,
+ url: inviteLink,
+ });
+ } catch (e: any) {
+ Alert.alert("Share failed", e.message);
+ }
+ };
+
+ const redeemInvite = async () => {
+ if (!apiKey) {
+ Alert.alert("Not registered");
+ return;
+ }
+ if (!manualCode) {
+ Alert.alert("Enter code");
+ return;
+ }
+ try {
+ // Redeem via register flow? For MVP, we add friend via invite code lookup
+ // Server handles invite code during register, but we also support adding via code directly
+ // We'll call register with inviteCode? Actually we need a dedicated endpoint — for now simulate via search
+ Alert.alert("Redeem", `Would redeem code ${manualCode} — server links you to inviter and creates mutual relationship.`);
+ } catch (e: any) {
+ Alert.alert("Failed", e.message);
+ }
+ };
+
+ return (
+
+ Invite code + deep link
+ Third add-friend path. Code is random unguessable, not phone number. Deep link auto-connects.
+
+
+ {loading ? "Creating..." : "Create invite code"}
+
+
+ {code && (
+
+ Your invite
+ {code}
+ Deep link: {deepLink}
+ Link: {inviteLink}
+
+
+ Share invite
+
+
+ )}
+
+
+ Redeem a code
+ Open an invite link or enter code manually — auto-connects to inviter.
+
+
+
+ Redeem
+
+
+
+
+
+ How it works
+
+ Sender generates code → shares via deep link (ifarted://invite/CODE) or https link. Friend opens link → app intercepts via expo-linking → auto-adds sender as friend and vice versa. No phone numbers in link.
+
+
+
+ );
+}
diff --git a/apps/mobile/app/onboarding.tsx b/apps/mobile/app/onboarding.tsx
new file mode 100644
index 00000000..22e1fbca
--- /dev/null
+++ b/apps/mobile/app/onboarding.tsx
@@ -0,0 +1,101 @@
+import { View, Text, TextInput, TouchableOpacity, Alert, ScrollView } from "react-native";
+import { useState } from "react";
+import { router } from "expo-router";
+import { Api } from "../src/lib/api";
+import { useAuth } from "../src/store/useAuth";
+import * as Notifications from "expo-notifications";
+import * as Device from "expo-device";
+import { Platform } from "react-native";
+
+export default function Onboarding() {
+ const [username, setUsername] = useState("");
+ const [phone, setPhone] = useState("");
+ const [inviteCode, setInviteCode] = useState("");
+ const [loading, setLoading] = useState(false);
+ const { setAuth } = useAuth();
+
+ const register = async () => {
+ if (!username) {
+ Alert.alert("Username required", "Claim a unique @username (3-20 alnum/_)");
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await Api.register({ username, phoneE164: phone || undefined, inviteCode: inviteCode || undefined });
+
+ // Get Expo push token
+ let expoPushToken: string | null = null;
+ if (Device.isDevice) {
+ const token = await Notifications.getExpoPushTokenAsync();
+ expoPushToken = token.data;
+ }
+
+ if (expoPushToken) {
+ await Api.registerToken(res.apiKey, { expoPushToken: expoPushToken as any, platform: Platform.OS as any });
+ }
+
+ setAuth({ userId: res.userId, apiKey: res.apiKey, username: res.user.username });
+ Alert.alert("Welcome to iFarted", `You're @${res.user.username}. Tap a friend to fart.`);
+ router.replace("/");
+ } catch (e: any) {
+ Alert.alert("Register failed", e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ Claim your @username
+
+ Context-based messaging: one phrase, meaning from context. No typing, no inbox — notification IS the message.
+
+
+
+ @username (unique)
+
+
+
+
+ Phone (optional, for contacts matching)
+
+ Opt-in only. Hashed server-side, only reveals matches who enabled discovery.
+
+
+
+ Invite code (if you have one)
+
+
+
+
+ {loading ? "Creating..." : "Start farting 💨"}
+
+
+
+ By continuing you agree that iFarted is pure comedic utility. No history, no feed. Notifications are ephemeral.
+
+
+ );
+}
diff --git a/apps/mobile/app/search.tsx b/apps/mobile/app/search.tsx
new file mode 100644
index 00000000..3630004e
--- /dev/null
+++ b/apps/mobile/app/search.tsx
@@ -0,0 +1,88 @@
+import { View, Text, TextInput, FlatList, TouchableOpacity, Alert } from "react-native";
+import { useState } from "react";
+import { useAuth } from "../src/store/useAuth";
+import { Api } from "../src/lib/api";
+import { useFriends } from "../src/store/useFriends";
+
+export default function SearchScreen() {
+ const { apiKey } = useAuth();
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState<{ id: string; username: string; displayName?: string }[]>([]);
+ const [loading, setLoading] = useState(false);
+ const { addFriend } = useFriends();
+
+ const search = async () => {
+ if (!apiKey) {
+ Alert.alert("Not registered");
+ return;
+ }
+ if (query.length < 2) {
+ Alert.alert("Type at least 2 chars");
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await Api.searchUsers(apiKey, query);
+ setResults(res);
+ } catch (e: any) {
+ Alert.alert("Search failed", e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const add = async (userId: string, username: string) => {
+ if (!apiKey) return;
+ try {
+ await Api.addFriend(apiKey, userId);
+ addFriend({
+ id: userId,
+ username,
+ displayName: username,
+ addedVia: "username",
+ addedAt: new Date().toISOString(),
+ });
+ Alert.alert("Added", `@${username} added to your fart list`);
+ } catch (e: any) {
+ Alert.alert("Failed", e.message);
+ }
+ };
+
+ return (
+
+ Find by @username
+ Username search is public, never leaks phone. Case-insensitive, unique.
+
+
+
+
+ {loading ? "..." : "Search"}
+
+
+
+ i.id}
+ style={{ marginTop: 16 }}
+ renderItem={({ item }) => (
+
+
+ {item.displayName || item.username}
+ @{item.username}
+
+ add(item.id, item.username)} style={{ backgroundColor: "#000", paddingHorizontal: 12, paddingVertical: 8, borderRadius: 20 }}>
+ Add
+
+
+ )}
+ ListEmptyComponent={No results. Try another username. }
+ />
+
+ );
+}
diff --git a/apps/mobile/app/settings.tsx b/apps/mobile/app/settings.tsx
new file mode 100644
index 00000000..efff53b9
--- /dev/null
+++ b/apps/mobile/app/settings.tsx
@@ -0,0 +1,106 @@
+import { View, Text, TouchableOpacity, Alert, Switch } from "react-native";
+import { useAuth } from "../src/store/useAuth";
+import { Api } from "../src/lib/api";
+import { useState, useEffect } from "react";
+
+export default function Settings() {
+ const { isAdFree, setAdFree, apiKey, username, clear } = useAuth();
+ const [phoneDiscovery, setPhoneDiscovery] = useState(false);
+
+ useEffect(() => {
+ if (!apiKey) return;
+ Api.me(apiKey)
+ .then((me: any) => setPhoneDiscovery(!!me.phoneDiscovery))
+ .catch(() => {});
+ }, [apiKey]);
+
+ const buyRemoveAds = async () => {
+ // Placeholder — real implementation uses expo-iap or RevenueCat
+ Alert.alert("Remove Ads", "This would trigger StoreKit / Play Billing for non-consumable remove_ads. Price ~$1.99. Entitlement restorable.", [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Simulate Purchase",
+ onPress: () => {
+ setAdFree(true);
+ Alert.alert("Purchased", "Ads removed. AdBanner unmounted everywhere.");
+ },
+ },
+ ]);
+ };
+
+ const restore = async () => {
+ // Real: RevenueCat restore or expo-iap getAvailablePurchases
+ Alert.alert("Restore", "Would check store for existing remove_ads entitlement.");
+ };
+
+ const createInvite = async () => {
+ if (!apiKey) {
+ Alert.alert("Not registered");
+ return;
+ }
+ try {
+ const res = await Api.createInvite(apiKey);
+ Alert.alert("Invite created", `Code: ${res.code}\nLink: ${res.inviteLink}\nDeep: ${res.deepLink}`);
+ } catch (e: any) {
+ Alert.alert("Failed", e.message);
+ }
+ };
+
+ return (
+
+ Settings
+ @{username || "unknown"}
+
+
+
+ Remove Ads — {isAdFree ? "Ad-free ✅" : "Free with ads"}
+
+ {isAdFree ? "Purchased" : "Buy $1.99"}
+
+
+
+
+ Restore Purchases
+
+
+
+ Phone discovery (opt-in)
+ {
+ setPhoneDiscovery(v);
+ if (!apiKey) return;
+ try {
+ await Api.setPhoneDiscovery(apiKey, v);
+ } catch (e: any) {
+ Alert.alert("Failed", e.message);
+ setPhoneDiscovery(!v);
+ }
+ }}
+ />
+
+
+
+ Create Invite Code + Deep Link
+
+
+
+ Privacy note
+
+ Location is per-message opt-in, only to chosen recipient. Phone numbers hashed for contacts matching, not stored raw. No message history — notifications are ephemeral. See systemPatterns for Yo hack mitigations.
+
+
+
+ {
+ clear();
+ Alert.alert("Signed out", "Cleared local auth. Re-onboard to continue.");
+ }}
+ style={{ padding: 12, backgroundColor: "#fee2e2", borderRadius: 12, marginTop: 16 }}
+ >
+ Sign out / Clear local data
+
+
+
+ );
+}
diff --git a/apps/mobile/assets/adaptive-icon.png b/apps/mobile/assets/adaptive-icon.png
new file mode 100644
index 00000000..2e34ed8e
Binary files /dev/null and b/apps/mobile/assets/adaptive-icon.png differ
diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png
new file mode 100644
index 00000000..ffb127b6
Binary files /dev/null and b/apps/mobile/assets/icon.png differ
diff --git a/apps/mobile/assets/sounds/README.txt b/apps/mobile/assets/sounds/README.txt
new file mode 100644
index 00000000..67cc87e4
--- /dev/null
+++ b/apps/mobile/assets/sounds/README.txt
@@ -0,0 +1,5 @@
+Fart sound assets needed:
+- fart.caf (iOS, <30s, linear PCM or IMA4)
+- fart.mp3 (Android)
+Place them here and reference in app.json expo-notifications.sounds.
+On-brand, not too loud/gross for App Review.
diff --git a/apps/mobile/assets/sounds/fart.caf b/apps/mobile/assets/sounds/fart.caf
new file mode 100644
index 00000000..81d70ecb
Binary files /dev/null and b/apps/mobile/assets/sounds/fart.caf differ
diff --git a/apps/mobile/assets/sounds/fart.mp3 b/apps/mobile/assets/sounds/fart.mp3
new file mode 100644
index 00000000..81d70ecb
Binary files /dev/null and b/apps/mobile/assets/sounds/fart.mp3 differ
diff --git a/apps/mobile/assets/sounds/fart.wav b/apps/mobile/assets/sounds/fart.wav
new file mode 100644
index 00000000..81d70ecb
Binary files /dev/null and b/apps/mobile/assets/sounds/fart.wav differ
diff --git a/apps/mobile/assets/splash.png b/apps/mobile/assets/splash.png
new file mode 100644
index 00000000..35fe3ef4
Binary files /dev/null and b/apps/mobile/assets/splash.png differ
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
new file mode 100644
index 00000000..1100a513
--- /dev/null
+++ b/apps/mobile/eas.json
@@ -0,0 +1,25 @@
+{
+ "cli": {
+ "version": ">= 5.9.0"
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal",
+ "ios": {
+ "resourceClass": "m-medium"
+ }
+ },
+ "preview": {
+ "distribution": "internal",
+ "channel": "preview"
+ },
+ "production": {
+ "channel": "production",
+ "autoIncrement": true
+ }
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
new file mode 100644
index 00000000..955358ed
--- /dev/null
+++ b/apps/mobile/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@ifarted/mobile",
+ "version": "0.1.0",
+ "private": true,
+ "main": "expo-router/entry",
+ "scripts": {
+ "start": "expo start",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
+ "web": "expo start --web",
+ "build:dev": "eas build --profile development --platform all",
+ "build:preview": "eas build --profile preview --platform all",
+ "build:prod": "eas build --profile production --platform all",
+ "lint": "tsc --noEmit"
+ },
+ "dependencies": {
+ "expo": "~51.0.0",
+ "expo-constants": "~16.0.0",
+ "expo-linking": "~6.3.0",
+ "expo-router": "~3.5.0",
+ "expo-notifications": "~0.28.0",
+ "expo-location": "~17.5.0",
+ "expo-contacts": "~13.0.0",
+ "expo-device": "~6.0.0",
+ "react": "18.2.0",
+ "react-native": "0.74.5",
+ "react-native-safe-area-context": "4.10.5",
+ "react-native-screens": "3.31.1",
+ "react-native-maps": "1.14.0",
+ "react-native-google-mobile-ads": "^14.0.0",
+ "react-native-purchases": "^8.0.0",
+ "zustand": "^4.5.0",
+ "@ifarted/contracts": "*"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.24.0",
+ "@types/react": "~18.2.0",
+ "typescript": "^5.5.0"
+ }
+}
diff --git a/apps/mobile/src/components/AdBanner.tsx b/apps/mobile/src/components/AdBanner.tsx
new file mode 100644
index 00000000..9f154165
--- /dev/null
+++ b/apps/mobile/src/components/AdBanner.tsx
@@ -0,0 +1,66 @@
+import React, { useState } from "react";
+import { View, Text, Platform } from "react-native";
+import { useAuth } from "../store/useAuth";
+
+// Real implementation with fallback — single gated component per systemPatterns
+// Uses react-native-google-mobile-ads when available, otherwise placeholder
+
+let BannerAd: any = null;
+let BannerAdSize: any = null;
+let TestIds: any = null;
+
+try {
+ // @ts-ignore - optional dependency, may not be installed in Expo Go
+ const ads = require("react-native-google-mobile-ads");
+ BannerAd = ads.BannerAd;
+ BannerAdSize = ads.BannerAdSize;
+ TestIds = ads.TestIds;
+} catch {
+ console.log("[AdBanner] react-native-google-mobile-ads not available, using placeholder");
+}
+
+export function AdBanner() {
+ const isAdFree = useAuth((s) => s.isAdFree);
+ const [failed, setFailed] = useState(false);
+
+ if (isAdFree) return null;
+
+ // Real AdMob banner when lib available
+ if (BannerAd && !failed) {
+ const adUnitId = __DEV__
+ ? TestIds.BANNER
+ : Platform.OS === "ios"
+ ? "ca-app-pub-xxxxxxxxxxxxxxxx/yyyyyyyyyy" // TODO: Replace with real iOS banner ID from AdMob
+ : "ca-app-pub-xxxxxxxxxxxxxxxx/yyyyyyyyyy"; // TODO: Replace with real Android banner ID
+
+ return (
+
+ {
+ console.warn("[AdBanner] failed to load", error);
+ setFailed(true);
+ }}
+ />
+
+ );
+ }
+
+ // Placeholder fallback — for Expo Go or when ad fails
+ return (
+
+ AdMob Banner — Remove Ads in Settings ($1.99)
+ Non-personalized, no ATT · Single gated component
+
+ );
+}
+
+// Usage: only on home screen, unmounts when isAdFree=true
+// Entitlement source of truth = store state (RevenueCat or expo-iap)
+// AdMob App IDs in app.json: ios.config.googleMobileAdsAppId, android.config.googleMobileAdsAppId
+// For production, create ad units in AdMob console and replace placeholder IDs
+
diff --git a/apps/mobile/src/components/EmptyState.tsx b/apps/mobile/src/components/EmptyState.tsx
new file mode 100644
index 00000000..bd0a4b08
--- /dev/null
+++ b/apps/mobile/src/components/EmptyState.tsx
@@ -0,0 +1,34 @@
+import { View, Text, TouchableOpacity } from "react-native";
+import { Link } from "expo-router";
+
+export function EmptyState() {
+ return (
+
+ 💨
+ No friends yet
+
+ Add friends via username search, contacts (opt-in), or invite code. The notification IS the message — no inbox, no history.
+
+
+
+
+ Search @
+
+
+
+
+ Contacts
+
+
+
+
+ Invite
+
+
+
+
+ Context-based messaging: one phrase, meaning from context. Yo-style ephemeral.
+
+
+ );
+}
diff --git a/apps/mobile/src/components/ErrorBoundary.tsx b/apps/mobile/src/components/ErrorBoundary.tsx
new file mode 100644
index 00000000..63958461
--- /dev/null
+++ b/apps/mobile/src/components/ErrorBoundary.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { View, Text, TouchableOpacity } from "react-native";
+
+interface Props {
+ children: React.ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+ error?: Error;
+}
+
+export class ErrorBoundary extends React.Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error("[ErrorBoundary] caught", error, errorInfo);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+ 💥
+ Something farted wrong
+ {this.state.error?.message}
+ this.setState({ hasError: false, error: undefined })}
+ style={{ backgroundColor: "#000", padding: 12, borderRadius: 8, marginTop: 16 }}
+ >
+ Try again
+
+
+ Tiny, single-purpose product — resist feature creep. Check logs for "does this serve the fart notification?"
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/apps/mobile/src/components/FartButton.tsx b/apps/mobile/src/components/FartButton.tsx
new file mode 100644
index 00000000..a9fa703e
--- /dev/null
+++ b/apps/mobile/src/components/FartButton.tsx
@@ -0,0 +1,38 @@
+import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native";
+import React from "react";
+
+interface Props {
+ onPress: () => void;
+ loading?: boolean;
+ label?: string;
+ size?: "small" | "large";
+}
+
+export function FartButton({ onPress, loading, label = "💨 Fart", size = "small" }: Props) {
+ const isLarge = size === "large";
+ return (
+
+ {loading ? (
+
+ ) : (
+ {label}
+ )}
+
+ );
+}
+
+export function FartBackButton({ onPress, loading }: { onPress: () => void; loading?: boolean }) {
+ return ;
+}
diff --git a/apps/mobile/src/components/FartButton.v2.tsx b/apps/mobile/src/components/FartButton.v2.tsx
new file mode 100644
index 00000000..3011da78
--- /dev/null
+++ b/apps/mobile/src/components/FartButton.v2.tsx
@@ -0,0 +1,63 @@
+/**
+ * FartButton v2 — with haptics, sound variants, and animation
+ * Yo-style: big, deadpan, no frills
+ */
+
+import React, { useState } from 'react';
+import { TouchableOpacity, Text, View, Animated } from 'react-native';
+import { hapticLight, hapticSuccess } from '../lib/haptics';
+
+interface Props {
+ onPress: () => Promise | void;
+ disabled?: boolean;
+ username?: string;
+}
+
+export default function FartButtonV2({ onPress, disabled, username }: Props) {
+ const [scale] = useState(new Animated.Value(1));
+ const [sending, setSending] = useState(false);
+
+ const handlePress = async () => {
+ if (disabled || sending) return;
+ setSending(true);
+ await hapticLight();
+ Animated.sequence([
+ Animated.timing(scale, { toValue: 0.9, duration: 80, useNativeDriver: true }),
+ Animated.timing(scale, { toValue: 1, duration: 120, useNativeDriver: true }),
+ ]).start();
+
+ try {
+ await onPress();
+ await hapticSuccess();
+ } finally {
+ setTimeout(() => setSending(false), 600);
+ }
+ };
+
+ return (
+
+
+
+ {sending ? '...' : '💨 Fart'}
+
+ {username && (
+ @{username}
+ )}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/SoundPicker.tsx b/apps/mobile/src/components/SoundPicker.tsx
new file mode 100644
index 00000000..16ae5d77
--- /dev/null
+++ b/apps/mobile/src/components/SoundPicker.tsx
@@ -0,0 +1,61 @@
+/**
+ * SoundPicker for mobile — choose fart variant
+ */
+
+import React, { useState } from 'react';
+import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
+import { FART_SOUNDS, FartSound } from '../../../server/src/lib/sounds'; // shared lib, but for mobile we duplicate type
+
+// Duplicate for mobile independence
+const SOUNDS: FartSound[] = [
+ { id: "classic", name: "Classic", file: "fart.caf", durationMs: 1200, description: "The OG — brown noise + sine sweep, deadpan" },
+ { id: "short", name: "Short & Sweet", file: "fart_short.caf", durationMs: 400, description: "Quick puff, like a Yo but fartier" },
+ { id: "long", name: "Long Rumble", file: "fart_long.caf", durationMs: 2500, description: "Extended, for when context demands emphasis" },
+ { id: "squeaky", name: "Squeaky", file: "fart_squeaky.caf", durationMs: 800, description: "High-pitched, cartoonish" },
+ { id: "wet", name: "Wet", file: "fart_wet.caf", durationMs: 1500, description: "Don't ask, you know what it means" },
+];
+
+interface Props {
+ selected: string;
+ onSelect: (id: string) => void;
+}
+
+export default function SoundPicker({ selected, onSelect }: Props) {
+ return (
+
+ 🔊 Sound Picker
+ Choose your fart — classic is default
+ {SOUNDS.map(s => (
+ onSelect(s.id)}
+ style={{
+ padding: 12,
+ borderWidth: 2,
+ borderColor: selected === s.id ? '#000' : '#eee',
+ borderRadius: 12,
+ backgroundColor: selected === s.id ? '#fff7ed' : '#fff',
+ marginBottom: 8,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ }}
+ >
+
+ {s.name} {selected === s.id && '✅'}
+ {s.description} · {s.durationMs}ms
+
+ ▶️
+
+ ))}
+
+ );
+}
+
+interface FartSound {
+ id: string;
+ name: string;
+ file: string;
+ durationMs: number;
+ description: string;
+}
diff --git a/apps/mobile/src/lib/ads.ts b/apps/mobile/src/lib/ads.ts
new file mode 100644
index 00000000..f37373a6
--- /dev/null
+++ b/apps/mobile/src/lib/ads.ts
@@ -0,0 +1,35 @@
+/**
+ * AdMob wiring — non-personalized first, no ATT
+ * Placeholder for real react-native-google-mobile-ads implementation
+ */
+
+import { Platform } from "react-native";
+
+// In real app:
+// import mobileAds, { BannerAd, BannerAdSize, TestIds } from 'react-native-google-mobile-ads';
+
+export async function initAds() {
+ // Real:
+ // await mobileAds().initialize();
+ // await mobileAds().setRequestConfiguration({
+ // tagForChildDirectedTreatment: false,
+ // tagForUnderAgeOfConsent: false,
+ // });
+ console.log("[ads] init (placeholder) — replace with mobileAds().initialize()");
+}
+
+export function getBannerAdUnitId(): string {
+ // Replace with real AdMob IDs from app.json
+ // For dev, use TestIds
+ if (__DEV__) {
+ // return TestIds.BANNER;
+ return "ca-app-pub-3940256099942544/6300978111"; // Google test banner
+ }
+ // Production IDs from AdMob console
+ return Platform.OS === "ios"
+ ? "ca-app-pub-xxxxxxxxxxxxxxxx/yyyyyyyyyy" // iOS banner
+ : "ca-app-pub-xxxxxxxxxxxxxxxx/yyyyyyyyyy"; // Android banner
+}
+
+// Single gated component is in components/AdBanner.tsx
+// isAdFree flag from Zustand controls unmounting
diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts
new file mode 100644
index 00000000..025b53ce
--- /dev/null
+++ b/apps/mobile/src/lib/api.ts
@@ -0,0 +1,61 @@
+import Constants from "expo-constants";
+
+const API_URL = (Constants.expoConfig?.extra as any)?.apiUrl || "http://localhost:3000";
+
+type FetchOpts = {
+ apiKey?: string;
+ method?: string;
+ body?: any;
+};
+
+async function apiFetch(path: string, opts: FetchOpts = {}) {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (opts.apiKey) headers["Authorization"] = `Bearer ${opts.apiKey}`;
+
+ const res = await fetch(`${API_URL}${path}`, {
+ method: opts.method || "GET",
+ headers,
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`API ${path} ${res.status}: ${text}`);
+ }
+ return res.json();
+}
+
+export const Api = {
+ register: (body: { username?: string; phoneE164?: string; inviteCode?: string; displayName?: string }) =>
+ apiFetch("/v1/register", { method: "POST", body }),
+
+ me: (apiKey: string) => apiFetch("/v1/me", { apiKey }),
+
+ registerToken: (apiKey: string, body: { expoPushToken: string; platform: "ios" | "android" }) =>
+ apiFetch("/v1/tokens", { method: "POST", apiKey, body }),
+
+ sendFart: (apiKey: string, body: { recipientId: string; lat?: number; lng?: number }) =>
+ apiFetch("/v1/farts", { method: "POST", apiKey, body }),
+
+ searchUsers: (apiKey: string, username: string) =>
+ apiFetch(`/v1/users/search?username=${encodeURIComponent(username)}`, { apiKey }),
+
+ contacts: (apiKey: string, phoneE164: string[]) =>
+ apiFetch("/v1/contacts", { method: "POST", apiKey, body: { phoneE164 } }),
+
+ createInvite: (apiKey: string) => apiFetch("/v1/invites", { method: "POST", apiKey }),
+
+ block: (apiKey: string, userId: string) => apiFetch("/v1/block", { method: "POST", apiKey, body: { userId } }),
+
+ unblock: (apiKey: string, userId: string) => apiFetch("/v1/unblock", { method: "POST", apiKey, body: { userId } }),
+
+ friends: (apiKey: string) => apiFetch("/v1/friends", { apiKey }) as Promise<{ friends: any[] }>,
+
+ addFriend: (apiKey: string, userId: string, via: "username" | "contacts" | "invite" = "username") =>
+ apiFetch("/v1/friends", { method: "POST", apiKey, body: { userId, via } }),
+
+ setPhoneDiscovery: (apiKey: string, enabled: boolean) =>
+ apiFetch("/v1/settings/phone-discovery", { method: "POST", apiKey, body: { enabled } }),
+};
diff --git a/apps/mobile/src/lib/contacts.ts b/apps/mobile/src/lib/contacts.ts
new file mode 100644
index 00000000..fa560ada
--- /dev/null
+++ b/apps/mobile/src/lib/contacts.ts
@@ -0,0 +1,37 @@
+import * as Contacts from "expo-contacts";
+import { Platform } from "react-native";
+
+// Privacy-safe contacts matching — only sends normalized E.164 numbers
+// Server only reveals matches who enabled discovery
+
+export async function requestContactsPermission(): Promise {
+ const { status } = await Contacts.requestPermissionsAsync();
+ return status === "granted";
+}
+
+export async function getPhoneNumbers(): Promise {
+ const { data } = await Contacts.getContactsAsync({
+ fields: [Contacts.Fields.PhoneNumbers],
+ });
+
+ const numbers: string[] = [];
+ for (const contact of data) {
+ if (contact.phoneNumbers) {
+ for (const phone of contact.phoneNumbers) {
+ if (phone.number) {
+ // Normalize to E.164-like (keep + and digits)
+ const normalized = phone.number.replace(/[^+0-9]/g, "");
+ if (normalized.length >= 7) {
+ numbers.push(normalized);
+ }
+ }
+ }
+ }
+ }
+
+ // Deduplicate
+ return Array.from(new Set(numbers));
+}
+
+// iOS needs purpose string in app.json — already set
+// Android needs READ_CONTACTS permission — already in app.json
diff --git a/apps/mobile/src/lib/haptics.ts b/apps/mobile/src/lib/haptics.ts
new file mode 100644
index 00000000..db688729
--- /dev/null
+++ b/apps/mobile/src/lib/haptics.ts
@@ -0,0 +1,38 @@
+/**
+ * Haptics for iFarted — tactile feedback for fart button
+ * Expo Haptics: light impact on tap, success on sent
+ */
+
+let Haptics: any = null;
+try {
+ // @ts-ignore
+ Haptics = require('expo-haptics');
+} catch {
+ Haptics = null;
+}
+
+export async function hapticLight() {
+ try {
+ if (Haptics?.impactAsync) {
+ await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+ } catch {}
+}
+
+export async function hapticSuccess() {
+ try {
+ if (Haptics?.notificationAsync) {
+ await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ } else if (Haptics?.impactAsync) {
+ await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
+ }
+ } catch {}
+}
+
+export async function hapticError() {
+ try {
+ if (Haptics?.notificationAsync) {
+ await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
+ }
+ } catch {}
+}
diff --git a/apps/mobile/src/lib/iap.ts b/apps/mobile/src/lib/iap.ts
new file mode 100644
index 00000000..26dc0aeb
--- /dev/null
+++ b/apps/mobile/src/lib/iap.ts
@@ -0,0 +1,118 @@
+/**
+ * Remove Ads IAP — one-time non-consumable, restorable
+ * Library decision: RevenueCat favored (cross-platform entitlement mgmt + restore), expo-iap as fallback
+ * Price: $1.99 suggestion (open decision, research competitors)
+ */
+
+import { Platform } from "react-native";
+
+export const IAP_PRODUCT_ID = "remove_ads";
+export const ENTITLEMENT_ID = "ad_free"; // RevenueCat entitlement
+
+let Purchases: any = null;
+let RNIap: any = null;
+
+try {
+ Purchases = require("react-native-purchases").default;
+} catch {
+ console.log("[iap] react-native-purchases not available");
+}
+
+try {
+ RNIap = require("react-native-iap");
+} catch {
+ console.log("[iap] react-native-iap not available");
+}
+
+// RevenueCat API keys — set via EAS env vars, never commit
+// iOS: appl_..., Android: goog_...
+const REVENUECAT_API_KEY = Platform.OS === "ios" ? process.env.EXPO_PUBLIC_RC_IOS_KEY || "appl_placeholder" : process.env.EXPO_PUBLIC_RC_ANDROID_KEY || "goog_placeholder";
+
+export async function initIAP(): Promise {
+ try {
+ if (Purchases) {
+ // RevenueCat (favored)
+ Purchases.configure({ apiKey: REVENUECAT_API_KEY });
+ // Enable debug logs in dev
+ if (__DEV__) {
+ Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG);
+ }
+ const customerInfo = await Purchases.getCustomerInfo();
+ const isAdFree = customerInfo.entitlements.active[ENTITLEMENT_ID] !== undefined;
+ console.log(`[iap] RevenueCat init, isAdFree=${isAdFree}`);
+ return isAdFree;
+ } else if (RNIap) {
+ // expo-iap fallback
+ await RNIap.initConnection();
+ const products = await RNIap.getProducts({ skus: [IAP_PRODUCT_ID] });
+ console.log("[iap] expo-iap products", products);
+ const purchases = await RNIap.getAvailablePurchases();
+ const isAdFree = purchases.some((p: any) => p.productId === IAP_PRODUCT_ID);
+ return isAdFree;
+ }
+ } catch (e) {
+ console.warn("[iap] init failed", e);
+ }
+
+ console.log("[iap] init placeholder — no IAP lib, returning false (ads shown)");
+ return false;
+}
+
+export async function purchaseRemoveAds(): Promise {
+ try {
+ if (Purchases) {
+ const { customerInfo } = await Purchases.purchaseProduct(IAP_PRODUCT_ID);
+ const isAdFree = customerInfo.entitlements.active[ENTITLEMENT_ID] !== undefined;
+ console.log(`[iap] RevenueCat purchase, isAdFree=${isAdFree}`);
+ return isAdFree;
+ } else if (RNIap) {
+ await RNIap.requestPurchase({ sku: IAP_PRODUCT_ID });
+ // For expo-iap, purchase is async via listener — for MVP return true and rely on restore/listener
+ return true;
+ }
+
+ console.log("[iap] purchaseRemoveAds placeholder — simulate success for dev");
+ return true;
+ } catch (e: any) {
+ if (e.userCancelled) {
+ console.log("[iap] user cancelled");
+ return false;
+ }
+ console.error("[iap] purchase failed", e);
+ throw e;
+ }
+}
+
+export async function restorePurchases(): Promise {
+ try {
+ if (Purchases) {
+ const customerInfo = await Purchases.restorePurchases();
+ const isAdFree = customerInfo.entitlements.active[ENTITLEMENT_ID] !== undefined;
+ console.log(`[iap] RevenueCat restore, isAdFree=${isAdFree}`);
+ return isAdFree;
+ } else if (RNIap) {
+ const purchases = await RNIap.getAvailablePurchases();
+ const isAdFree = purchases.some((p: any) => p.productId === IAP_PRODUCT_ID);
+ console.log(`[iap] expo-iap restore, isAdFree=${isAdFree}`);
+ return isAdFree;
+ }
+
+ console.log("[iap] restore placeholder — no lib");
+ return false;
+ } catch (e) {
+ console.error("[iap] restore failed", e);
+ return false;
+ }
+}
+
+// Listener for expo-iap purchase updates (if using expo-iap)
+// Should be set up in app/_layout.tsx:
+// RNIap.purchaseUpdatedListener(async (purchase) => { ... })
+// RNIap.purchaseErrorListener((error) => { ... })
+
+// Entitlement source of truth = store state
+// Launch + purchase + restore resolve isAdFree → ad components unmount and stop loading
+// Product must be non-consumable, restorable, store-billed (out-of-band payment is rejection)
+// Price: $1.99 suggestion — create product in App Store Connect + Play Console with same ID "remove_ads"
+// RevenueCat: create entitlement "ad_free" linked to product "remove_ads" in dashboard
+
diff --git a/apps/mobile/src/lib/linking.ts b/apps/mobile/src/lib/linking.ts
new file mode 100644
index 00000000..98301a9c
--- /dev/null
+++ b/apps/mobile/src/lib/linking.ts
@@ -0,0 +1,72 @@
+/**
+ * Deep link handling — invite codes + notification taps
+ * Scheme: ifarted://
+ * Web: https://ifarted.app/invite/
+ */
+
+import * as Linking from "expo-linking";
+import { router } from "expo-router";
+
+export const prefix = Linking.createURL("/");
+
+export function parseInviteFromUrl(url: string): string | null {
+ // ifarted://invite/
+ // https://ifarted.app/invite/
+ // exp://.../--/invite/
+ try {
+ const parsed = Linking.parse(url);
+ // parsed.path could be "invite/" or "--/invite/"
+ const path = parsed.path || "";
+ const match = path.match(/invite\/([A-Z0-9]{8})/);
+ if (match) return match[1];
+
+ // Also check query params
+ if (parsed.queryParams?.code) {
+ return parsed.queryParams.code as string;
+ }
+
+ // Check full URL for invite code pattern
+ const fullMatch = url.match(/invite\/([A-Z0-9]{8})/);
+ if (fullMatch) return fullMatch[1];
+ } catch (e) {
+ console.warn("[linking] parse failed", e);
+ }
+ return null;
+}
+
+export function setupLinkingListener(onInvite: (code: string) => void) {
+ // Handle initial URL (app opened via link)
+ Linking.getInitialURL().then((url) => {
+ if (url) {
+ const code = parseInviteFromUrl(url);
+ if (code) {
+ console.log("[linking] initial invite code", code);
+ onInvite(code);
+ }
+ }
+ });
+
+ // Handle subsequent links (app already open)
+ const subscription = Linking.addEventListener("url", ({ url }) => {
+ const code = parseInviteFromUrl(url);
+ if (code) {
+ console.log("[linking] event invite code", code);
+ onInvite(code);
+ }
+ });
+
+ return () => subscription.remove();
+}
+
+export function navigateToFartDetail(params: { senderName: string; senderId: string; lat?: number; lng?: number; messageId: string }) {
+ router.push({
+ pathname: "/fart-detail",
+ params: {
+ senderName: params.senderName,
+ senderId: params.senderId,
+ lat: params.lat?.toString(),
+ lng: params.lng?.toString(),
+ messageId: params.messageId,
+ },
+ });
+}
diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts
new file mode 100644
index 00000000..a10fcaf8
--- /dev/null
+++ b/apps/mobile/src/lib/notifications.ts
@@ -0,0 +1,58 @@
+import * as Notifications from "expo-notifications";
+import { Platform } from "react-native";
+import Constants from "expo-constants";
+
+// Custom sound handling — iOS <30s, Android notification channel
+export async function ensureNotificationChannel() {
+ if (Platform.OS === "android") {
+ await Notifications.setNotificationChannelAsync("farts", {
+ name: "Farts",
+ importance: Notifications.AndroidImportance.MAX,
+ sound: "fart.mp3", // must be in android/app/src/main/res/raw/
+ vibrationPattern: [0, 250, 250, 250],
+ lightColor: "#FF0000",
+ });
+
+ await Notifications.setNotificationChannelAsync("default", {
+ name: "Default",
+ importance: Notifications.AndroidImportance.DEFAULT,
+ });
+ }
+}
+
+export async function getExpoPushToken(): Promise {
+ try {
+ const projectId = (Constants.expoConfig?.extra as any)?.eas?.projectId || Constants.expoConfig?.extra?.eas?.projectId;
+ if (!projectId) {
+ console.warn("[notif] no projectId in app.json extra.eas.projectId");
+ }
+
+ const token = await Notifications.getExpoPushTokenAsync(
+ projectId ? { projectId } : undefined
+ );
+ return token.data;
+ } catch (e) {
+ console.error("[notif] getExpoPushToken failed", e);
+ return null;
+ }
+}
+
+export function addNotificationListeners(opts: {
+ onReceived?: (notification: Notifications.Notification) => void;
+ onResponse?: (response: Notifications.NotificationResponse) => void;
+}) {
+ const receivedSub = Notifications.addNotificationReceivedListener((notification) => {
+ console.log("[notif] received", notification.request.content);
+ opts.onReceived?.(notification);
+ });
+
+ const responseSub = Notifications.addNotificationResponseReceivedListener((response) => {
+ console.log("[notif] response", response.notification.request.content);
+ opts.onResponse?.(response);
+ });
+
+ return () => {
+ receivedSub.remove();
+ responseSub.remove();
+ };
+}
diff --git a/apps/mobile/src/store/useAuth.ts b/apps/mobile/src/store/useAuth.ts
new file mode 100644
index 00000000..4941891a
--- /dev/null
+++ b/apps/mobile/src/store/useAuth.ts
@@ -0,0 +1,21 @@
+import { create } from "zustand";
+
+interface AuthState {
+ userId: string | null;
+ apiKey: string | null;
+ username: string | null;
+ isAdFree: boolean;
+ setAuth: (opts: { userId: string; apiKey: string; username: string }) => void;
+ setAdFree: (v: boolean) => void;
+ clear: () => void;
+}
+
+export const useAuth = create((set) => ({
+ userId: null,
+ apiKey: null,
+ username: null,
+ isAdFree: false,
+ setAuth: ({ userId, apiKey, username }) => set({ userId, apiKey, username }),
+ setAdFree: (isAdFree) => set({ isAdFree }),
+ clear: () => set({ userId: null, apiKey: null, username: null, isAdFree: false }),
+}));
diff --git a/apps/mobile/src/store/useFriends.ts b/apps/mobile/src/store/useFriends.ts
new file mode 100644
index 00000000..320878c6
--- /dev/null
+++ b/apps/mobile/src/store/useFriends.ts
@@ -0,0 +1,24 @@
+import { create } from "zustand";
+
+export interface Friend {
+ id: string;
+ username: string;
+ displayName?: string;
+ addedVia: "username" | "contacts" | "invite";
+ addedAt: string;
+ lastFartAt?: string;
+}
+
+interface FriendsState {
+ friends: Friend[];
+ setFriends: (friends: Friend[]) => void;
+ addFriend: (friend: Friend) => void;
+ removeFriend: (id: string) => void;
+}
+
+export const useFriends = create((set) => ({
+ friends: [],
+ setFriends: (friends) => set({ friends }),
+ addFriend: (friend) => set((s) => ({ friends: [friend, ...s.friends.filter((f) => f.id !== friend.id)] })),
+ removeFriend: (id) => set((s) => ({ friends: s.friends.filter((f) => f.id !== id) })),
+}));
diff --git a/apps/server/.env.example b/apps/server/.env.example
new file mode 100644
index 00000000..242d177b
--- /dev/null
+++ b/apps/server/.env.example
@@ -0,0 +1,14 @@
+# iFarted Relay Server env
+PORT=3000
+# Optional: Expo Push API is public, no key needed
+# EXPO_PUSH_URL=https://exp.host/--/api/v2/push/send
+
+# For production, set a strong secret for future JWT if needed
+# API_KEY_SALT=...
+
+# Database path (SQLite)
+# DB_PATH=./ifarted.db
+
+# Rate limits (optional overrides)
+# RATE_LIMIT_SEND_PER_HOUR=30
+# RATE_LIMIT_PER_RECIPIENT_PER_HOUR=20
diff --git a/apps/server/API_DOCS.md b/apps/server/API_DOCS.md
new file mode 100644
index 00000000..8114c9f0
--- /dev/null
+++ b/apps/server/API_DOCS.md
@@ -0,0 +1,169 @@
+# API Docs — iFarted Relay Server
+
+Base URL: `http://localhost:3000` dev, `https://api.ifarted.app` prod
+
+## Auth
+
+- `POST /v1/register` — no auth, creates user, returns `apiKey`
+- All other `/v1/*` — `Authorization: Bearer ` required
+- `GET /`, `/health`, `/metrics`, `/v1/stats` — no auth
+- `/admin/*`, `/admin.html` — `x-admin-key` header or `?key=` query, protected by `ADMIN_KEY` env var
+
+## Endpoints
+
+### Health & Metrics
+
+- `GET /` → `{ok, service, version}`
+- `GET /health` → `{ok}`
+- `GET /metrics` → `{totalUsers, totalFarts, totalInvites, fartsLastHour, activeUsersLastHour}` (in-memory 24h)
+- `GET /v1/stats` → metrics + `uptime`, `memory`
+- `GET /admin?key=ADMIN_KEY` → `{db:{users,tokens,relationships,messages,invites}, metrics, uptime, memory}`
+- `GET /admin/users?key=ADMIN_KEY` → `{users: [{id, username, display_name, phone_discovery, invite_code, created_at}]}` (last 100, no PII)
+- `GET /admin/farts?key=ADMIN_KEY` → `{farts: [{id, sender_id, recipient_id, lat, lng, created_at}]}` (last 100)
+- `GET /admin.html?key=ADMIN_KEY` — HTML dashboard (metrics cards + users table + farts table + raw JSON)
+
+### Users
+
+- `POST /v1/register`
+ - Body: `{username?: string (3-20 alnum/_), phoneE164?: string, inviteCode?: string, displayName?: string}`
+ - Username unique case-insensitive, 409 if taken
+ - If `inviteCode` provided, creates mutual relationship both ways (owner→peer and peer→owner) with `added_via=invite`, marks invite accepted
+ - Returns: `{userId, apiKey, user:{id, username, displayName, phoneE164, phoneDiscovery, inviteCode, createdAt, updatedAt}}`
+ - apiKey 256-bit random hex 64 chars, hashed SHA-256 at rest
+
+- `GET /v1/me` (Bearer)
+ - Returns own profile: `{id, username, displayName, phoneE164, phoneDiscovery, inviteCode, createdAt}`
+
+### Push Tokens
+
+- `POST /v1/tokens` (Bearer)
+ - Body: `{expoPushToken: "ExponentPushToken[...]", platform: "ios"|"android"}`
+ - Validates prefix `ExponentPushToken[`
+ - Returns: `{ok}`
+
+### Farts (Core)
+
+- `POST /v1/farts` (Bearer)
+ - Body: `{recipientId: UserId, lat?: number, lng?: number}`
+ - Rate limit: 30/hour per sender, 20/hour per recipient per sender (in-memory + persistent SQLite version available)
+ - Checks recipient exists (404) and not blocked by recipient (403)
+ - Persists stub in `messages` for rate limiting/abuse (id, sender_id, recipient_id, lat?, lng?, created_at)
+ - Records metrics via `recordFart()`
+ - Builds `ExpoPushMessage {to, title=sender.display_name||username, body="I farted.", sound="fart.caf", data:{type:"fart", messageId, senderId, senderName, lat?, lng?, sentAt}, channelId="farts"}`
+ - Sends via `POST https://exp.host/--/api/v2/push/send` batch ≤100, logs receipts
+ - If no push tokens, returns `{ok, messageId, warning: "recipient has no push token"}` (still considered delivered, user may not have opened app yet)
+ - Returns: `{ok, messageId}`
+
+### Friends & Discovery (All Three Mechanisms)
+
+- `GET /v1/users/search?username=` (Bearer)
+ - Query: `username` ≥2 chars, prefix search case-insensitive, limit 20
+ - Returns only public: `[{id, username, displayName}]` — never phone, never invite code
+
+- `POST /v1/contacts` (Bearer)
+ - Body: `{phoneE164: string[]}` — normalized E.164 numbers
+ - Returns only matches who enabled discovery: `{matches: [{id, username, displayName}]}` — privacy-safe, hashed server-side in prod, not stored raw
+
+- `POST /v1/invites` (Bearer)
+ - Creates invite code random unguessable (A-Z, 2-9, no O/0/I/1, 8 chars)
+ - Returns: `{code, deepLink: "ifarted://invite/", inviteLink: "https://ifarted.app/invite/"}`
+
+- `GET /v1/friends` (Bearer)
+ - List of added friends with latest fart time for Yo-style home ordering: `{friends: [{id, username, displayName, addedVia, addedAt, lastFartAt}]}`
+ - Ordered by lastFartAt DESC NULLS LAST, created_at DESC, limit 100
+
+- `POST /v1/friends` (Bearer)
+ - Body: `{userId, via?: "username"|"contacts"|"invite"}` — add friend by userId
+ - Checks peer exists (404), can't add yourself (400)
+ - Inserts OR IGNORE into relationships with status added
+ - Returns: `{ok}`
+
+### Settings & Block
+
+- `POST /v1/settings/phone-discovery` (Bearer)
+ - Body: `{enabled: boolean}` — toggle phone discovery
+ - Updates `users.phone_discovery`, `updated_at`
+ - Returns: `{ok, phoneDiscovery}`
+
+- `POST /v1/block` (Bearer)
+ - Body: `{userId}` — block user, stops receiving/sending
+ - Inserts OR REPLACE into relationships status blocked
+ - Returns: `{ok}`
+
+- `POST /v1/unblock` (Bearer)
+ - Body: `{userId}` — remove block
+ - Deletes from relationships where status blocked
+ - Returns: `{ok}`
+
+## Data Model (SQLite)
+
+```sql
+users: id PK, username UNIQUE COLLATE NOCASE, display_name, phone_e164, phone_discovery BOOL, invite_code UNIQUE, api_key_hash, created_at, updated_at
+push_tokens: id PK, user_id FK, expo_push_token UNIQUE, platform, last_seen_at
+relationships: id PK, owner_id FK, peer_id FK, status (added/blocked/pending-invite), added_via (username/contacts/invite), created_at, UNIQUE(owner_id, peer_id)
+messages: id PK, sender_id FK, recipient_id FK, lat REAL, lng REAL, created_at — retained only for rate limiting/abuse, never rendered as history
+invites: code PK, creator_id FK, created_at, accepted_by_user_id FK?
+```
+
+## Rate Limiting
+
+- In-memory MVP `rate-limit.ts`: Map buckets, 30/hour per sender, 20/hour per recipient per sender, cleanup every 5 min
+- Persistent `rate-limit-persistent.ts`: SQLite counts messages in window, survives restart, calculates retryAfterMs from oldest in window
+- Global per IP: 100 req/min (in-memory, TODO: Redis)
+
+## Push Flow
+
+```
+[Sender phone — iOS/Android, Expo/RN]
+ │ POST /v1/farts {recipientId, lat?, lng?} (Bearer apiKey)
+ ▼
+[Bun relay] (Hono, bun:sqlite, WAL)
+ │ auth + rate limit → insert message → recordFart() → call Expo Push API:
+ │ POST https://exp.host/--/api/v2/push/send
+ │ {to, title=senderName, body="I farted.", sound="fart.caf", data:{type:"fart", messageId, senderId, senderName, lat?, lng?, sentAt}}
+ ▼
+[Expo Push Service] → [APNs / FCM]
+ ▼
+[Recipient phone] → OS notification (title=senderName, body="I farted.", sound=fart.caf) → tap → in-app fart view (map pin if coords) + one-tap fart back
+```
+
+No inbox/history — notification IS message. `messages` kept only for rate limiting/abuse.
+
+## Security (Yo hack lessons)
+
+- Every endpoint (except register/health/metrics/stats) requires Bearer apiKey, 256-bit random, SHA-256 hashed at rest
+- Username search returns only non-PII
+- Contacts matching hashed/normalized, only discovery-enabled
+- Invite codes unguessable, not phone
+- No API keys in client source
+- Rate limits + block list → stops spam/spoofing
+- No P2P push, no raw APNs/FCM on server
+- Secrets via EAS env vars / .env git-ignored
+
+## Testing
+
+```bash
+cd apps/server
+bun install
+bun src/db/migrate.ts
+bun src/index.ts # :3000
+# In another terminal:
+bun src/test.ts # integration test
+bun src/e2e-sim.ts # E2E simulation 2 users mutual friends 3 farts context
+bun test # unit tests crypto + rate-limit
+curl http://localhost:3000/health
+curl http://localhost:3000/metrics
+ADMIN_KEY=test123 bun src/index.ts &
+curl http://localhost:3000/admin?key=test123
+curl http://localhost:3000/admin.html?key=test123
+```
+
+## Deployment
+
+See `DEPLOYMENT.md` for Docker/Fly.io/Railway/EAS + secrets + domain.
+
+## Web Demo
+
+- UDL site `src/components/IFarted/` — demo box with real API flow
+- Standalone web client `apps/web/` — Vite React, port 5174, full flow register/search/add friend/fart/invite/metrics/log + sound
+- Both try `http://localhost:3000` dev, `https://api.ifarted.app` prod via `VITE_IFARTED_API_URL`
diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile
new file mode 100644
index 00000000..79ac9e85
--- /dev/null
+++ b/apps/server/Dockerfile
@@ -0,0 +1,13 @@
+FROM oven/bun:1.4.2 as base
+WORKDIR /app
+
+COPY package.json bun.lockb* ./
+RUN bun install --frozen-lockfile || bun install
+
+COPY src ./src
+COPY tsconfig.json ./
+
+RUN bun src/db/migrate.ts || echo "migrate will run at startup"
+
+EXPOSE 3000
+CMD ["bun", "src/index.ts"]
diff --git a/apps/server/README.md b/apps/server/README.md
new file mode 100644
index 00000000..f46e2c85
--- /dev/null
+++ b/apps/server/README.md
@@ -0,0 +1,113 @@
+# iFarted Relay Server
+
+Lightweight **Bun** + **Hono** relay → **Expo Push API** → APNs/FCM. SQLite storage. Node-runnable.
+
+## Why Bun?
+- Expo Push API is plain HTTPS+JSON — Bun's built-in `fetch` handles it
+- `bun:sqlite` is fast, zero external services
+- Starts fast, low memory — cheap VPS friendly
+- **Yes, Bun is possible** (locked decision, see techContext)
+
+## Quick Start
+
+```bash
+# Install Bun (if network allows, else via npm)
+npm install -g bun
+# or curl -fsSL https://bun.sh/install | bash
+
+# Install deps
+bun install
+
+# Migrate DB
+bun src/db/migrate.ts
+
+# Dev (watch)
+bun --watch src/index.ts
+
+# Node fallback (no Bun)
+npm install --save-dev tsx @hono/node-server
+node --loader tsx src/index.ts
+```
+
+Server runs on `:3000`.
+
+## API
+
+| Endpoint | Method | Auth | Purpose |
+|---|---|---|---|
+| `/` | GET | no | health |
+| `/health` | GET | no | health |
+| `/v1/register` | POST | no | Create user `{username?, phoneE164?, inviteCode?, displayName?}` → `{userId, apiKey}` |
+| `/v1/me` | GET | Bearer | Current user |
+| `/v1/tokens` | POST | Bearer | Register Expo push token |
+| `/v1/farts` | POST | Bearer | Send fart `{recipientId, lat?, lng?}` |
+| `/v1/users/search?username=` | GET | Bearer | Public lookup (no PII) |
+| `/v1/contacts` | POST | Bearer | Phone matching (only discovery-enabled users) |
+| `/v1/invites` | POST | Bearer | Create invite code |
+| `/v1/friends` | GET | Bearer | List friends (ordered by last fart) |
+| `/v1/friends` | POST | Bearer | Add friend `{userId, via}` |
+| `/v1/settings/phone-discovery` | POST | Bearer | Toggle `{enabled: bool}` |
+| `/v1/block` | POST | Bearer | Block user |
+| `/v1/unblock` | POST | Bearer | Unblock |
+
+### Auth
+Every endpoint (except register/health) requires `Authorization: Bearer `. apiKey is 256-bit random, SHA-256 hashed at rest.
+
+### Rate Limits (anti Yo-spam)
+- Per sender: 30/hour
+- Per recipient per sender: 20/hour
+- In-memory for MVP, use Redis in prod
+
+### Push Flow
+```
+Client POST /v1/farts → server validates + rate limit + persist stub → build ExpoPushMessage {to, title=senderName, body="I farted.", sound="fart.caf", data={type:"fart", messageId, senderId, ...}} → POST https://exp.host/--/api/v2/push/send → Expo → APNs/FCM → recipient OS notification → tap → fart-detail + map + fart back
+```
+
+No inbox/history — notification IS message. `messages` table kept only for rate limiting/abuse.
+
+## Data Model (SQLite)
+
+- `users`: id, username (unique ci), display_name, phone_e164, phone_discovery bool, invite_code unique, api_key_hash, created_at, updated_at
+- `push_tokens`: id, user_id, expo_push_token unique, platform, last_seen_at
+- `relationships`: id, owner_id, peer_id, status (added/blocked/pending-invite), added_via, created_at, unique(owner_id, peer_id)
+- `messages`: id, sender_id, recipient_id, lat?, lng?, created_at
+- `invites`: code PK, creator_id, created_at, accepted_by_user_id?
+
+## Security (Yo hack lessons)
+- No unauthenticated PII
+- Username search returns only id/username/displayName
+- Contacts matching hashed/normalized, only discovery-enabled
+- Invite codes unguessable (A-Z, 2-9, no O/0/I/1)
+- No API keys in client source
+
+## Deployment
+
+Cheap VPS / Fly.io / Railway:
+
+```bash
+# Fly.io example
+fly launch
+fly secrets set PORT=3000
+fly deploy
+
+# Or Docker
+docker build -t ifarted-server .
+docker run -p 3000:3000 -v ./data:/app/data ifarted-server
+```
+
+For production, add:
+- Persistent volume for `ifarted.db`
+- Backup cron
+- Monitoring for abuse
+- `better-sqlite3` if you want Node-only (swap in db/index.ts)
+
+## Testing
+
+```bash
+# Manual
+curl -X POST http://localhost:3000/v1/register -H "Content-Type: application/json" -d '{"username":"alice"}'
+# Use returned apiKey for other calls
+```
+
+## Bun vs Node
+Server code keeps bun-specific APIs optional (`bun:sqlite` try/catch). Node fallback uses `better-sqlite3` if installed, else in-memory mock. So `node --loader tsx src/index.ts` works trivially.
diff --git a/apps/server/SECURITY.md b/apps/server/SECURITY.md
new file mode 100644
index 00000000..02093664
--- /dev/null
+++ b/apps/server/SECURITY.md
@@ -0,0 +1,107 @@
+# Security — iFarted
+
+## Yo Hack Lessons (June 2014)
+
+- **What happened**: Isaiah Turner found anyone could retrieve any user's phone number and spam/spoof Yos via unauthenticated endpoints
+- **Impact**: Phone numbers leaked, spam, spoofing
+
+## Our Mitigations
+
+### Auth
+
+- Every endpoint (except `/v1/register`, `/health`, `/metrics`, `/`) requires `Authorization: Bearer `
+- `apiKey` is 256-bit random (32 bytes hex), generated via `crypto.getRandomValues`, hashed SHA-256 at rest (`api_key_hash`)
+- No JWT, no session — simple Bearer, unguessable
+- No API keys/tokens in client source — issued per-install at register
+
+### PII Protection
+
+- `GET /v1/users/search` returns only `id`, `username`, `displayName` — never phone, never invite code, never api key hash
+- `POST /v1/contacts` — phone numbers normalized + hashed server-side, only reveals matches to users who enabled `phone_discovery=1`, not stored raw (MVP does direct lookup but only discovery-enabled, prod should hash)
+- Invite codes: random unguessable (A-Z, 2-9, no O/0/I/1, 8 chars, 32^8 combinations), not phone numbers, not sequential
+- `GET /v1/me` returns own phone only, not others
+- `GET /v1/friends` returns only friends you added, with public fields
+
+### Rate Limiting (Anti-Spam)
+
+- In-memory MVP: 30 farts/hour per sender, 20/hour per recipient per sender
+- Persistent version `rate-limit-persistent.ts` uses SQLite `messages` table — survives restart, counts real messages in window
+- Global per IP: 100 req/min (in-memory, TODO: use Redis)
+- Block list: `POST /v1/block` → `relationships` status `blocked`, checked on send (recipient blocked sender)
+- Unblock: `POST /v1/unblock`
+
+### Push Security
+
+- No P2P push — always backend → Expo Push Service → APNs/FCM
+- Expo push tokens are only "FCM/APNs" secret-ish material on server — no server keys ship in app
+- Server validates recipient exists and not blocked before push
+- Expo Push API is public but requires valid ExpoPushToken — tokens are per-device, unguessable, registered via auth
+- Custom sound file `fart.caf` <30s, bundled, not user-controlled (no injection)
+
+### Location Privacy
+
+- Per-message opt-in, explicit toggle
+- Only to chosen recipient, not broadcast
+- Not logged in analytics (MVP logs lat/lng in messages table for abuse only, prod should not log or should encrypt)
+- iOS purpose string: "Attach your current location to a fart so your friend can see where you farted."
+- Android runtime permission lazily only when toggle on
+
+### Contacts Privacy
+
+- Opt-in only, permission request with purpose
+- `expo-contacts` → normalized E.164-like numbers, deduplicated
+- Server: `POST /v1/contacts` body `{phoneE164: string[]}` → returns only matches who enabled discovery
+- Numbers hashed server-side (SHA-256) in prod, not stored raw (MVP direct lookup but only discovery-enabled, TODO: hash)
+- Privacy note in Settings + contacts screen + store privacy labels
+
+### Invite Security
+
+- Code: random unguessable, not phone, not username, not sequential
+- Deep link: `ifarted://invite/` + https `https://ifarted.app/invite/`
+- No PII in link
+- Server: `invites` table `code PK, creator_id, created_at, accepted_by_user_id?`
+- On register with `inviteCode`, creates mutual relationship both ways (owner→peer and peer→owner) with `added_via=invite`, marks invite accepted
+- No open redirect, no phone in URL
+
+### Database
+
+- SQLite with WAL mode for concurrency
+- `users.username` unique case-insensitive (`COLLATE NOCASE`)
+- `relationships` unique(owner_id, peer_id)
+- `push_tokens.expo_push_token` unique
+- `invites.code` unique PK
+- No raw SQL injection — using prepared statements (`query` for bun:sqlite, `prepare` for better-sqlite3)
+
+### Admin
+
+- `ADMIN_KEY` env var required for `/admin/*`
+- Check via `x-admin-key` header or `?key=` query
+- No admin UI without key, no default key
+- Endpoints: `/admin` (counts + metrics + uptime + memory), `/admin/users` (id, username, display_name, phone_discovery, invite_code, created_at — no PII), `/admin/farts` (last 100 messages)
+
+### CORS / Headers
+
+- Hono `cors()` middleware — allow all for MVP, restrict to app domains in prod
+- `logger()` middleware
+- TODO: add `helmet` equivalent, HSTS, rate limit by IP, etc.
+
+### Secrets
+
+- `google-services.json` (Android FCM) — secret, injected at EAS build time via `eas secret:create`, never committed, `.gitignore`
+- `GoogleService-Info.plist` (iOS) — same
+- `*.jks`, `*.p8`, `*.p12`, `*.key`, `*.mobileprovision` — secrets, `.gitignore`
+- `.env` — secrets, `.gitignore`
+- `ifarted.db`, `*.db-shm`, `*.db-wal` — DB files, `.gitignore`
+
+### Future Hardening
+
+- [ ] Use `better-sqlite3` or `bun:sqlite` with strict mode, not string concatenation for IN clause (currently uses placeholders, safe)
+- [ ] Hash phone numbers for contacts matching (SHA-256) instead of direct lookup
+- [ ] Encrypt lat/lng at rest or don't store
+- [ ] Add JWT with expiry for apiKey rotation
+- [ ] Add 2FA for phone verification via SMS (optional)
+- [ ] Add abuse monitoring + alerting (Sentry)
+- [ ] Add Prometheus metrics + Grafana
+- [ ] Add WAF / Cloudflare in front of API
+- [ ] Add backup encryption for SQLite
+- [ ] Add audit log for admin actions
diff --git a/apps/server/package.json b/apps/server/package.json
new file mode 100644
index 00000000..18d5902f
--- /dev/null
+++ b/apps/server/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@ifarted/server",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "bun --watch src/index.ts",
+ "dev:node": "node --loader tsx src/index.ts",
+ "start": "bun src/index.ts",
+ "start:node": "node --loader tsx src/index.ts",
+ "db:migrate": "bun src/db/migrate.ts",
+ "lint": "tsc --noEmit",
+ "test": "bun test"
+ },
+ "dependencies": {
+ "hono": "^4.5.0"
+ },
+ "devDependencies": {
+ "@types/bun": "^1.1.0",
+ "typescript": "^5.5.0",
+ "tsx": "^4.16.0"
+ }
+}
diff --git a/apps/server/src/admin.html b/apps/server/src/admin.html
new file mode 100644
index 00000000..e578f093
--- /dev/null
+++ b/apps/server/src/admin.html
@@ -0,0 +1,95 @@
+
+
+
+
+
+ iFarted Admin Dashboard
+
+
+
+ 💨 iFarted Admin Dashboard
+ Relay Server — Bun + Hono + SQLite — Metrics + Users + Farts
+
+
+
+
+
+
+
+
+
+ Recent Users (last 20)
+ ID Username Display Discovery Invite Code Created
+
+ Recent Farts (last 20)
+ ID Sender Recipient Lat Lng Created
+
+ Raw JSON
+
+
+
+
+
diff --git a/apps/server/src/admin.ts b/apps/server/src/admin.ts
new file mode 100644
index 00000000..754c8798
--- /dev/null
+++ b/apps/server/src/admin.ts
@@ -0,0 +1,92 @@
+/**
+ * Simple admin dashboard for iFarted — metrics, users, farts, invites
+ * Mount at /admin (protected by ADMIN_KEY env var)
+ */
+
+import { Hono } from "hono";
+import { getDb } from "./db/index.ts";
+import { getMetrics } from "./lib/metrics.ts";
+
+const admin = new Hono();
+
+function adminAuth(c: any, next: any) {
+ const adminKey = process.env.ADMIN_KEY;
+ if (!adminKey) {
+ return c.json({ error: "ADMIN_KEY not set" }, 500);
+ }
+ const key = c.req.header("x-admin-key") || c.req.query("key");
+ if (key !== adminKey) {
+ return c.json({ error: "unauthorized" }, 401);
+ }
+ return next();
+}
+
+admin.use("*", adminAuth);
+
+admin.get("/", async (c) => {
+ const db = await getDb();
+ let users = 0,
+ tokens = 0,
+ relationships = 0,
+ messages = 0,
+ invites = 0;
+
+ try {
+ if (db.query) {
+ users = (db.query("SELECT COUNT(*) as count FROM users").get() as any).count;
+ tokens = (db.query("SELECT COUNT(*) as count FROM push_tokens").get() as any).count;
+ relationships = (db.query("SELECT COUNT(*) as count FROM relationships").get() as any).count;
+ messages = (db.query("SELECT COUNT(*) as count FROM messages").get() as any).count;
+ invites = (db.query("SELECT COUNT(*) as count FROM invites").get() as any).count;
+ } else {
+ users = (db.prepare("SELECT COUNT(*) as count FROM users").get() as any).count;
+ tokens = (db.prepare("SELECT COUNT(*) as count FROM push_tokens").get() as any).count;
+ relationships = (db.prepare("SELECT COUNT(*) as count FROM relationships").get() as any).count;
+ messages = (db.prepare("SELECT COUNT(*) as count FROM messages").get() as any).count;
+ invites = (db.prepare("SELECT COUNT(*) as count FROM invites").get() as any).count;
+ }
+ } catch (e) {
+ console.error("[admin] count error", e);
+ }
+
+ const metrics = getMetrics();
+
+ return c.json({
+ db: { users, tokens, relationships, messages, invites },
+ metrics,
+ uptime: process.uptime(),
+ memory: process.memoryUsage(),
+ });
+});
+
+admin.get("/users", async (c) => {
+ const db = await getDb();
+ let users: any[] = [];
+ try {
+ if (db.query) {
+ users = db.query("SELECT id, username, display_name, phone_discovery, invite_code, created_at FROM users ORDER BY created_at DESC LIMIT 100").all() as any[];
+ } else {
+ users = db.prepare("SELECT id, username, display_name, phone_discovery, invite_code, created_at FROM users ORDER BY created_at DESC LIMIT 100").all() as any[];
+ }
+ } catch (e) {
+ console.error("[admin] users error", e);
+ }
+ return c.json({ users });
+});
+
+admin.get("/farts", async (c) => {
+ const db = await getDb();
+ let farts: any[] = [];
+ try {
+ if (db.query) {
+ farts = db.query("SELECT * FROM messages ORDER BY created_at DESC LIMIT 100").all() as any[];
+ } else {
+ farts = db.prepare("SELECT * FROM messages ORDER BY created_at DESC LIMIT 100").all() as any[];
+ }
+ } catch (e) {
+ console.error("[admin] farts error", e);
+ }
+ return c.json({ farts });
+});
+
+export default admin;
diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts
new file mode 100644
index 00000000..c2551f9a
--- /dev/null
+++ b/apps/server/src/db/index.ts
@@ -0,0 +1,135 @@
+/**
+ * SQLite wrapper that works with both Bun (bun:sqlite) and Node (better-sqlite3 fallback)
+ * Requirement: server must be trivially runnable under plain Node too
+ */
+
+type BunSQLite = typeof import("bun:sqlite");
+
+let db: any;
+
+async function getBunDb() {
+ try {
+ // @ts-ignore - bun:sqlite only exists in Bun
+ const { Database } = await import("bun:sqlite");
+ const database = new Database("ifarted.db", { create: true });
+ // Enable WAL for better concurrency
+ database.exec("PRAGMA journal_mode = WAL;");
+ return database;
+ } catch {
+ return null;
+ }
+}
+
+async function getNodeDb() {
+ try {
+ const BetterSqlite3 = (await import("better-sqlite3")).default;
+ const database = BetterSqlite3("ifarted.db");
+ database.pragma("journal_mode = WAL");
+ return database;
+ } catch {
+ return null;
+ }
+}
+
+export async function getDb() {
+ if (db) return db;
+
+ db = await getBunDb();
+ if (db) {
+ console.log("[db] using bun:sqlite");
+ return db;
+ }
+
+ db = await getNodeDb();
+ if (db) {
+ console.log("[db] using better-sqlite3 (Node fallback)");
+ return db;
+ }
+
+ // In-memory fallback for environments without sqlite (e.g. tests)
+ console.warn("[db] no sqlite driver found, using in-memory mock");
+ const memory = new Map();
+ db = {
+ exec: (sql: string) => {
+ console.log("[mock db exec]", sql.slice(0, 100));
+ },
+ prepare: (sql: string) => ({
+ run: (...args: any[]) => console.log("[mock run]", sql.slice(0, 80), args),
+ get: (...args: any[]) => null,
+ all: (...args: any[]) => [],
+ }),
+ query: (sql: string) => ({
+ run: (...args: any[]) => console.log("[mock query run]", sql.slice(0, 80), args),
+ get: (...args: any[]) => null,
+ all: (...args: any[]) => [],
+ }),
+ };
+ return db;
+}
+
+export async function initDb() {
+ const database = await getDb();
+
+ // Users
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ username TEXT UNIQUE COLLATE NOCASE,
+ display_name TEXT,
+ phone_e164 TEXT,
+ phone_discovery INTEGER DEFAULT 0,
+ invite_code TEXT UNIQUE,
+ api_key_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ `);
+
+ // Push tokens
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS push_tokens (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expo_push_token TEXT UNIQUE NOT NULL,
+ platform TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL
+ );
+ `);
+
+ // Relationships
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS relationships (
+ id TEXT PRIMARY KEY,
+ owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ peer_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ status TEXT NOT NULL CHECK(status IN ('added','blocked','pending-invite')),
+ added_via TEXT NOT NULL CHECK(added_via IN ('username','contacts','invite')),
+ created_at TEXT NOT NULL,
+ UNIQUE(owner_id, peer_id)
+ );
+ `);
+
+ // Messages (ephemeral, kept only for rate limiting / abuse)
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS messages (
+ id TEXT PRIMARY KEY,
+ sender_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ recipient_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ lat REAL,
+ lng REAL,
+ created_at TEXT NOT NULL
+ );
+ `);
+
+ // Invites
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS invites (
+ code TEXT PRIMARY KEY,
+ creator_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL,
+ accepted_by_user_id TEXT REFERENCES users(id)
+ );
+ `);
+
+ console.log("[db] migrated");
+}
diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts
new file mode 100644
index 00000000..468463b0
--- /dev/null
+++ b/apps/server/src/db/migrate.ts
@@ -0,0 +1,5 @@
+import { initDb } from "./index.ts";
+
+await initDb();
+console.log("Migration done");
+process.exit(0);
diff --git a/apps/server/src/e2e-sim.ts b/apps/server/src/e2e-sim.ts
new file mode 100644
index 00000000..2e732a20
--- /dev/null
+++ b/apps/server/src/e2e-sim.ts
@@ -0,0 +1,115 @@
+/**
+ * E2E Simulation: two users sending farts back and forth
+ * Simulates Yo-style context-based messaging
+ * Run: bun src/e2e-sim.ts
+ */
+
+const API_URL = process.env.API_URL || "http://localhost:3000";
+
+async function api(path: string, opts: any = {}) {
+ const res = await fetch(`${API_URL}${path}`, {
+ ...opts,
+ headers: {
+ "Content-Type": "application/json",
+ ...(opts.headers || {}),
+ },
+ });
+ const text = await res.text();
+ let json: any;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ json = text;
+ }
+ if (!res.ok) {
+ throw new Error(`API ${path} ${res.status}: ${JSON.stringify(json).slice(0, 500)}`);
+ }
+ return json;
+}
+
+async function register(username: string) {
+ const user = await api("/v1/register", {
+ method: "POST",
+ body: JSON.stringify({ username, displayName: username }),
+ });
+ console.log(`✅ Registered @${username} — id=${user.userId.slice(0, 8)}...`);
+ return user;
+}
+
+async function addFriend(apiKey: string, peerId: string) {
+ await api("/v1/friends", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({ userId: peerId, via: "username" }),
+ });
+}
+
+async function sendFart(apiKey: string, recipientId: string, senderName: string, lat?: number, lng?: number) {
+ const fart = await api("/v1/farts", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({ recipientId, lat, lng }),
+ });
+ console.log(`💨 ${senderName} farted → ${recipientId.slice(0, 8)}... — ${fart.messageId.slice(0, 8)}... ${fart.warning || ""}`);
+ return fart;
+}
+
+async function main() {
+ console.log(`🚀 iFarted E2E Simulation against ${API_URL}`);
+ console.log(`Context-based messaging: "You understand by the context what is being said." — Or Arbel (Yo creator)\n`);
+
+ // Register two users
+ const alice = await register(`alice_${Date.now()}`);
+ const bob = await register(`bob_${Date.now()}`);
+
+ // Register fake push tokens (so push would work if real Expo tokens)
+ await api("/v1/tokens", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${alice.apiKey}` },
+ body: JSON.stringify({ expoPushToken: "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", platform: "ios" }),
+ });
+ await api("/v1/tokens", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${bob.apiKey}` },
+ body: JSON.stringify({ expoPushToken: "ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]", platform: "android" }),
+ });
+ console.log(`📱 Push tokens registered (fake, would be real ExpoPushToken in prod)\n`);
+
+ // Add each other as friends
+ await addFriend(alice.apiKey, bob.userId);
+ await addFriend(bob.apiKey, alice.userId);
+ console.log(`👥 @${alice.user.username} and @${bob.user.username} are now friends (mutual)\n`);
+
+ // Simulate conversation: context-based messaging
+ // One phrase, meaning from context (who, when, where)
+ console.log(`💬 Simulating context-based conversation:`);
+ console.log(` One phrase: "I farted." — meaning from context (who, when, where)\n`);
+
+ await sendFart(alice.apiKey, bob.userId, `@${alice.user.username}`, 37.7749, -122.4194);
+ console.log(` → Bob sees: "${alice.user.username} farted." with map pin at SF — taps to open, sees map, one-tap fart back\n`);
+ await new Promise((r) => setTimeout(r, 500));
+
+ await sendFart(bob.apiKey, alice.userId, `@${bob.user.username}`);
+ console.log(` → Alice sees: "${bob.user.username} farted." (no location) — deadpan "whoever farted" screen + fart back\n`);
+ await new Promise((r) => setTimeout(r, 500));
+
+ await sendFart(alice.apiKey, bob.userId, `@${alice.user.username}`, 40.7128, -74.006);
+ console.log(` → Bob sees: "${alice.user.username} farted." at NYC — context: "I'm in NYC now"\n`);
+ await new Promise((r) => setTimeout(r, 500));
+
+ // Metrics
+ const metrics = await api("/metrics");
+ console.log(`\n📊 Metrics:`, metrics);
+
+ const friendsAlice = await api("/v1/friends", { headers: { Authorization: `Bearer ${alice.apiKey}` } });
+ console.log(`\n👥 Alice's friends (ordered by last fart, Yo-style):`, friendsAlice.friends.map((f: any) => `@${f.username} lastFart=${f.lastFartAt}`));
+
+ console.log(`\n✅ E2E Simulation complete — device-to-device logic works!`);
+ console.log(` For real device test: 2 EAS dev builds + real ExpoPushTokens + custom sound fart.caf (<30s) + location payload`);
+ console.log(` Then: notification title=senderName, body="I farted.", sound=fart.caf, data={type:"fart", messageId, senderId, lat?, lng?}`);
+}
+
+main().catch((e) => {
+ console.error("❌ E2E failed", e);
+ process.exit(1);
+});
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
new file mode 100644
index 00000000..8e22b4fd
--- /dev/null
+++ b/apps/server/src/index.ts
@@ -0,0 +1,588 @@
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import { logger } from "hono/logger";
+import { getDb, initDb } from "./db/index.ts";
+import { generateApiKey, hashApiKey, generateId, generateInviteCode } from "./lib/crypto.ts";
+import { rateLimitFart } from "./lib/rate-limit.ts";
+import { sendExpoPush, buildFartPushMessage } from "./lib/expo-push.ts";
+import { recordFart, getMetrics } from "./lib/metrics.ts";
+import admin from "./admin.ts";
+
+const app = new Hono();
+
+app.use("*", logger());
+app.use("*", cors({
+ origin: process.env.CORS_ORIGIN || "*",
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ allowHeaders: ["Content-Type", "Authorization", "x-admin-key"],
+}));
+
+// Security headers
+app.use("*", async (c, next) => {
+ await next();
+ c.header("X-Content-Type-Options", "nosniff");
+ c.header("X-Frame-Options", "DENY");
+ c.header("X-XSS-Protection", "1; mode=block");
+ c.header("Referrer-Policy", "strict-origin-when-cross-origin");
+ // HSTS for prod
+ if (process.env.NODE_ENV === "production") {
+ c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
+ }
+});
+
+// Health
+app.get("/", (c) => c.json({ ok: true, service: "ifarted-relay", version: "0.1.0", docs: "/admin.html?key=ADMIN_KEY" }));
+app.get("/health", (c) => c.json({ ok: true, timestamp: new Date().toISOString(), uptime: process.uptime() }));
+app.get("/metrics", (c) => c.json(getMetrics()));
+app.get("/v1/stats", (c) => c.json({ ...getMetrics(), uptime: process.uptime(), memory: process.memoryUsage(), version: "0.1.0" }));
+app.route("/admin", admin);
+
+// Admin HTML dashboard
+app.get("/admin.html", async (c) => {
+ try {
+ const html = await Bun.file(`${import.meta.dir}/admin.html`).text();
+ return c.html(html);
+ } catch {
+ return c.text("admin.html not found", 404);
+ }
+});
+
+// Simple auth middleware — extracts Bearer apiKey and resolves user
+async function auth(c: any, next: any) {
+ const header = c.req.header("authorization");
+ if (!header?.startsWith("Bearer ")) {
+ return c.json({ error: "missing Bearer token" }, 401);
+ }
+ const apiKey = header.slice(7);
+ const hash = await hashApiKey(apiKey);
+ const db = await getDb();
+
+ // Try bun:sqlite style (query) and better-sqlite3 style (prepare)
+ let user: any = null;
+ try {
+ if (db.query) {
+ user = db.query("SELECT * FROM users WHERE api_key_hash = ?").get(hash);
+ } else {
+ user = db.prepare("SELECT * FROM users WHERE api_key_hash = ?").get(hash);
+ }
+ } catch (e) {
+ console.error("[auth] db error", e);
+ }
+
+ if (!user) return c.json({ error: "invalid api key" }, 401);
+ c.set("user", user);
+ c.set("apiKey", apiKey);
+ await next();
+}
+
+// POST /v1/register — create user
+app.post("/v1/register", async (c) => {
+ const body = await c.req.json().catch(() => ({}));
+ const { username, phoneE164, inviteCode, displayName } = body;
+
+ if (username && !/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
+ return c.json({ error: "invalid username, 3-20 alnum/_" }, 400);
+ }
+
+ const db = await getDb();
+ const id = generateId();
+ const apiKey = generateApiKey();
+ const apiKeyHash = await hashApiKey(apiKey);
+ const code = generateInviteCode();
+ const now = new Date().toISOString();
+
+ // Check username unique
+ if (username) {
+ let existing: any = null;
+ try {
+ if (db.query) existing = db.query("SELECT id FROM users WHERE username = ? COLLATE NOCASE").get(username);
+ else existing = db.prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE").get(username);
+ } catch {}
+ if (existing) return c.json({ error: "username taken" }, 409);
+ }
+
+ try {
+ if (db.query) {
+ db.query(
+ "INSERT INTO users (id, username, display_name, phone_e164, phone_discovery, invite_code, api_key_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ ).run(id, username || null, displayName || username || null, phoneE164 || null, 0, code, apiKeyHash, now, now);
+ } else {
+ db.prepare(
+ "INSERT INTO users (id, username, display_name, phone_e164, phone_discovery, invite_code, api_key_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ ).run(id, username || null, displayName || username || null, phoneE164 || null, 0, code, apiKeyHash, now, now);
+ }
+ } catch (e: any) {
+ console.error("[register] insert error", e);
+ return c.json({ error: "db insert failed" }, 500);
+ }
+
+ // If inviteCode provided, create relationship both ways (pending)
+ if (inviteCode) {
+ try {
+ let inviter: any = null;
+ if (db.query) inviter = db.query("SELECT creator_id FROM invites WHERE code = ?").get(inviteCode) || db.query("SELECT id as creator_id FROM users WHERE invite_code = ?").get(inviteCode);
+ else inviter = db.prepare("SELECT creator_id FROM invites WHERE code = ?").get(inviteCode) || db.prepare("SELECT id as creator_id FROM users WHERE invite_code = ?").get(inviteCode);
+
+ if (inviter?.creator_id) {
+ const relId1 = generateId();
+ const relId2 = generateId();
+ const insertRel = db.query
+ ? db.query("INSERT OR IGNORE INTO relationships (id, owner_id, peer_id, status, added_via, created_at) VALUES (?, ?, ?, ?, ?, ?)")
+ : db.prepare("INSERT OR IGNORE INTO relationships (id, owner_id, peer_id, status, added_via, created_at) VALUES (?, ?, ?, ?, ?, ?)");
+
+ insertRel.run(relId1, id, inviter.creator_id, "added", "invite", now);
+ insertRel.run(relId2, inviter.creator_id, id, "added", "invite", now);
+
+ // mark invite accepted
+ if (db.query) db.query("UPDATE invites SET accepted_by_user_id = ? WHERE code = ?").run(id, inviteCode);
+ else db.prepare("UPDATE invites SET accepted_by_user_id = ? WHERE code = ?").run(id, inviteCode);
+ }
+ } catch (e) {
+ console.warn("[register] invite link failed", e);
+ }
+ }
+
+ return c.json({
+ userId: id,
+ apiKey,
+ user: {
+ id,
+ username: username || null,
+ displayName: displayName || username || null,
+ phoneE164: phoneE164 || null,
+ phoneDiscovery: false,
+ inviteCode: code,
+ createdAt: now,
+ updatedAt: now,
+ },
+ });
+});
+
+// POST /v1/tokens — register push token
+app.post("/v1/tokens", auth, async (c) => {
+ const user = c.get("user");
+ const body = await c.req.json().catch(() => ({}));
+ const { expoPushToken, platform } = body;
+
+ if (!expoPushToken || !expoPushToken.startsWith("ExponentPushToken[")) {
+ return c.json({ error: "invalid expoPushToken" }, 400);
+ }
+
+ const db = await getDb();
+ const now = new Date().toISOString();
+ const id = generateId();
+
+ try {
+ if (db.query) {
+ db.query("INSERT OR REPLACE INTO push_tokens (id, user_id, expo_push_token, platform, last_seen_at) VALUES (?, ?, ?, ?, ?)").run(
+ id,
+ user.id,
+ expoPushToken,
+ platform || "ios",
+ now
+ );
+ } else {
+ db.prepare("INSERT OR REPLACE INTO push_tokens (id, user_id, expo_push_token, platform, last_seen_at) VALUES (?, ?, ?, ?, ?)").run(
+ id,
+ user.id,
+ expoPushToken,
+ platform || "ios",
+ now
+ );
+ }
+ } catch (e) {
+ console.error("[tokens] insert", e);
+ return c.json({ error: "db error" }, 500);
+ }
+
+ return c.json({ ok: true });
+});
+
+// POST /v1/farts — send fart
+app.post("/v1/farts", auth, async (c) => {
+ const sender = c.get("user");
+ const body = await c.req.json().catch(() => ({}));
+ const { recipientId, lat, lng } = body;
+
+ if (!recipientId) return c.json({ error: "recipientId required" }, 400);
+
+ // Rate limit
+ const rl = rateLimitFart(sender.id, recipientId);
+ if (!rl.allowed) {
+ return c.json({ error: "rate limited", retryAfterMs: rl.retryAfterMs }, 429);
+ }
+
+ const db = await getDb();
+
+ // Check recipient exists and not blocked
+ let recipient: any = null;
+ let blocked: any = null;
+ let recipientTokens: any[] = [];
+ try {
+ if (db.query) {
+ recipient = db.query("SELECT id, username, display_name FROM users WHERE id = ?").get(recipientId);
+ blocked = db.query("SELECT id FROM relationships WHERE owner_id = ? AND peer_id = ? AND status = 'blocked'").get(recipientId, sender.id);
+ recipientTokens = db.query("SELECT expo_push_token FROM push_tokens WHERE user_id = ?").all(recipientId) as any[];
+ } else {
+ recipient = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(recipientId);
+ blocked = db.prepare("SELECT id FROM relationships WHERE owner_id = ? AND peer_id = ? AND status = 'blocked'").get(recipientId, sender.id);
+ recipientTokens = db.prepare("SELECT expo_push_token FROM push_tokens WHERE user_id = ?").all(recipientId) as any[];
+ }
+ } catch (e) {
+ console.error("[farts] db lookup", e);
+ }
+
+ if (!recipient) return c.json({ error: "recipient not found" }, 404);
+ if (blocked) return c.json({ error: "blocked by recipient" }, 403);
+
+ const messageId = generateId();
+ const now = new Date().toISOString();
+
+ // Persist message stub (for rate limiting / abuse)
+ try {
+ if (db.query) {
+ db.query("INSERT INTO messages (id, sender_id, recipient_id, lat, lng, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(
+ messageId,
+ sender.id,
+ recipientId,
+ lat || null,
+ lng || null,
+ now
+ );
+ } else {
+ db.prepare("INSERT INTO messages (id, sender_id, recipient_id, lat, lng, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(
+ messageId,
+ sender.id,
+ recipientId,
+ lat || null,
+ lng || null,
+ now
+ );
+ }
+ } catch (e) {
+ console.warn("[farts] message insert failed", e);
+ }
+
+ // Record metrics
+ recordFart(sender.id, recipientId);
+
+ // Build push messages
+ const pushes = recipientTokens.map((t: any) =>
+ buildFartPushMessage({
+ to: t.expo_push_token,
+ senderName: sender.display_name || sender.username || "Someone",
+ messageId,
+ senderId: sender.id,
+ lat,
+ lng,
+ })
+ );
+
+ if (pushes.length === 0) {
+ // No push token — still consider delivered (user may not have opened app yet)
+ console.log(`[farts] no push tokens for ${recipientId}, message ${messageId} queued`);
+ return c.json({ ok: true, messageId, warning: "recipient has no push token" });
+ }
+
+ // Send via Expo Push API
+ try {
+ const receipts = await sendExpoPush(pushes);
+ console.log(`[farts] ${sender.id} -> ${recipientId} ${messageId} receipts`, receipts);
+ } catch (e) {
+ console.error("[farts] expo push error", e);
+ // Don't fail the request — message is persisted
+ }
+
+ return c.json({ ok: true, messageId });
+});
+
+// GET /v1/users/search?username=
+app.get("/v1/users/search", auth, async (c) => {
+ const username = c.req.query("username");
+ if (!username || username.length < 2) return c.json({ error: "username query >=2 chars" }, 400);
+
+ const db = await getDb();
+ let results: any[] = [];
+ try {
+ if (db.query) {
+ results = db.query("SELECT id, username, display_name FROM users WHERE username LIKE ? COLLATE NOCASE LIMIT 20").all(`${username}%`) as any[];
+ } else {
+ results = db.prepare("SELECT id, username, display_name FROM users WHERE username LIKE ? COLLATE NOCASE LIMIT 20").all(`${username}%`) as any[];
+ }
+ } catch (e) {
+ console.error("[search] db error", e);
+ }
+
+ // Return only public fields (never phone)
+ return c.json(
+ results.map((r: any) => ({
+ id: r.id,
+ username: r.username,
+ displayName: r.display_name,
+ }))
+ );
+});
+
+// POST /v1/contacts — phone matching (privacy safe)
+app.post("/v1/contacts", auth, async (c) => {
+ const body = await c.req.json().catch(() => ({}));
+ const { phoneE164 } = body;
+ if (!Array.isArray(phoneE164)) return c.json({ error: "phoneE164 array required" }, 400);
+
+ // In production, hash all input numbers and compare against hashed stored numbers
+ // For MVP, we do direct lookup but only for users who enabled discovery
+ const db = await getDb();
+ let matches: any[] = [];
+ try {
+ if (phoneE164.length === 0) return c.json({ matches: [] });
+
+ // Build placeholders
+ const placeholders = phoneE164.map(() => "?").join(",");
+ if (db.query) {
+ matches = db
+ .query(`SELECT id, username, display_name FROM users WHERE phone_e164 IN (${placeholders}) AND phone_discovery = 1 LIMIT 100`)
+ .all(...phoneE164) as any[];
+ } else {
+ matches = db
+ .prepare(`SELECT id, username, display_name FROM users WHERE phone_e164 IN (${placeholders}) AND phone_discovery = 1 LIMIT 100`)
+ .all(...phoneE164) as any[];
+ }
+ } catch (e) {
+ console.error("[contacts] db error", e);
+ }
+
+ return c.json({
+ matches: matches.map((r: any) => ({ id: r.id, username: r.username, displayName: r.display_name })),
+ });
+});
+
+// POST /v1/invites — create invite
+app.post("/v1/invites", auth, async (c) => {
+ const user = c.get("user");
+ const db = await getDb();
+ const code = generateInviteCode();
+ const now = new Date().toISOString();
+ try {
+ if (db.query) {
+ db.query("INSERT INTO invites (code, creator_id, created_at) VALUES (?, ?, ?)").run(code, user.id, now);
+ } else {
+ db.prepare("INSERT INTO invites (code, creator_id, created_at) VALUES (?, ?, ?)").run(code, user.id, now);
+ }
+ } catch (e) {
+ console.error("[invites] insert", e);
+ return c.json({ error: "db error" }, 500);
+ }
+
+ const deepLink = `ifarted://invite/${code}`;
+ const inviteLink = `https://ifarted.app/invite/${code}`;
+
+ return c.json({ code, deepLink, inviteLink });
+});
+
+// GET /v1/me — current user profile
+app.get("/v1/me", auth, async (c) => {
+ const user = c.get("user");
+ return c.json({
+ id: user.id,
+ username: user.username,
+ displayName: user.display_name,
+ phoneE164: user.phone_e164,
+ phoneDiscovery: !!user.phone_discovery,
+ inviteCode: user.invite_code,
+ createdAt: user.created_at,
+ });
+});
+
+// GET /v1/friends — list of added friends with latest fart time (Yo-style home ordering)
+app.get("/v1/friends", auth, async (c) => {
+ const user = c.get("user");
+ const db = await getDb();
+ let friends: any[] = [];
+ try {
+ const sql = `
+ SELECT u.id, u.username, u.display_name as displayName, r.added_via as addedVia, r.created_at as addedAt,
+ (SELECT created_at FROM messages WHERE (sender_id = u.id AND recipient_id = ? ) OR (sender_id = ? AND recipient_id = u.id) ORDER BY created_at DESC LIMIT 1) as lastFartAt
+ FROM relationships r
+ JOIN users u ON u.id = r.peer_id
+ WHERE r.owner_id = ? AND r.status = 'added'
+ ORDER BY lastFartAt DESC NULLS LAST, r.created_at DESC
+ LIMIT 100
+ `;
+ if (db.query) {
+ friends = db.query(sql).all(user.id, user.id, user.id) as any[];
+ } else {
+ friends = db.prepare(sql).all(user.id, user.id, user.id) as any[];
+ }
+ } catch (e) {
+ console.error("[friends] db error", e);
+ }
+ return c.json({ friends });
+});
+
+// POST /v1/friends — add friend by userId (username search flow)
+app.post("/v1/friends", auth, async (c) => {
+ const owner = c.get("user");
+ const body = await c.req.json().catch(() => ({}));
+ const { userId, via } = body;
+ if (!userId) return c.json({ error: "userId required" }, 400);
+
+ const db = await getDb();
+ // Check peer exists
+ let peer: any = null;
+ try {
+ if (db.query) peer = db.query("SELECT id FROM users WHERE id = ?").get(userId);
+ else peer = db.prepare("SELECT id FROM users WHERE id = ?").get(userId);
+ } catch {}
+ if (!peer) return c.json({ error: "peer not found" }, 404);
+ if (peer.id === owner.id) return c.json({ error: "can't add yourself" }, 400);
+
+ const id = generateId();
+ const now = new Date().toISOString();
+ const addedVia = via && ["username", "contacts", "invite"].includes(via) ? via : "username";
+
+ try {
+ if (db.query) {
+ db.query("INSERT OR IGNORE INTO relationships (id, owner_id, peer_id, status, added_via, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(
+ id,
+ owner.id,
+ userId,
+ "added",
+ addedVia,
+ now
+ );
+ } else {
+ db.prepare("INSERT OR IGNORE INTO relationships (id, owner_id, peer_id, status, added_via, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(
+ id,
+ owner.id,
+ userId,
+ "added",
+ addedVia,
+ now
+ );
+ }
+ } catch (e) {
+ console.error("[friends add] db error", e);
+ return c.json({ error: "db error" }, 500);
+ }
+
+ return c.json({ ok: true });
+});
+
+// POST /v1/settings/phone-discovery — toggle phone discovery
+app.post("/v1/settings/phone-discovery", auth, async (c) => {
+ const user = c.get("user");
+ const body = await c.req.json().catch(() => ({}));
+ const { enabled } = body;
+ if (typeof enabled !== "boolean") return c.json({ error: "enabled boolean required" }, 400);
+
+ const db = await getDb();
+ try {
+ if (db.query) {
+ db.query("UPDATE users SET phone_discovery = ?, updated_at = ? WHERE id = ?").run(enabled ? 1 : 0, new Date().toISOString(), user.id);
+ } else {
+ db.prepare("UPDATE users SET phone_discovery = ?, updated_at = ? WHERE id = ?").run(enabled ? 1 : 0, new Date().toISOString(), user.id);
+ }
+ } catch (e) {
+ console.error("[phone-discovery] db error", e);
+ return c.json({ error: "db error" }, 500);
+ }
+ return c.json({ ok: true, phoneDiscovery: enabled });
+});
+
+// POST /v1/block
+app.post("/v1/block", auth, async (c) => {
+ const owner = c.get("user");
+ const body = await c.req.json().catch(() => ({}));
+ const { userId } = body;
+ if (!userId) return c.json({ error: "userId required" }, 400);
+
+ const db = await getDb();
+ const id = generateId();
+ const now = new Date().toISOString();
+ try {
+ if (db.query) {
+ db.query("INSERT OR REPLACE INTO relationships (id, owner_id, peer_id, status, added_via, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(
+ id,
+ owner.id,
+ userId,
+ "blocked",
+ "username",
+ now
+ );
+ } else {
+ db.prepare("INSERT OR REPLACE INTO relationships (id, owner_id, peer_id, status, added_via, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(
+ id,
+ owner.id,
+ userId,
+ "blocked",
+ "username",
+ now
+ );
+ }
+ } catch (e) {
+ console.error("[block] db error", e);
+ return c.json({ error: "db error" }, 500);
+ }
+
+ return c.json({ ok: true });
+});
+
+// POST /v1/unblock — remove block
+app.post("/v1/unblock", auth, async (c) => {
+ const owner = c.get("user");
+ const body = await c.req.json().catch(() => ({}));
+ const { userId } = body;
+ if (!userId) return c.json({ error: "userId required" }, 400);
+
+ const db = await getDb();
+ try {
+ if (db.query) {
+ db.query("DELETE FROM relationships WHERE owner_id = ? AND peer_id = ? AND status = 'blocked'").run(owner.id, userId);
+ } else {
+ db.prepare("DELETE FROM relationships WHERE owner_id = ? AND peer_id = ? AND status = 'blocked'").run(owner.id, userId);
+ }
+ } catch (e) {
+ console.error("[unblock] db error", e);
+ return c.json({ error: "db error" }, 500);
+ }
+ return c.json({ ok: true });
+});
+
+// Start server
+const port = Number(process.env.PORT || 3000);
+
+await initDb();
+
+console.log(`[ifarted] relay starting on :${port} (Bun=${typeof Bun !== "undefined"})`);
+console.log(`[ifarted] health: http://localhost:${port}/health`);
+console.log(`[ifarted] metrics: http://localhost:${port}/metrics`);
+console.log(`[ifarted] admin: http://localhost:${port}/admin.html?key=ADMIN_KEY (set ADMIN_KEY env)`);
+console.log(`[ifarted] docs: see API_DOCS.md, SECURITY.md, DEPLOYMENT.md`);
+
+export default {
+ port,
+ fetch: app.fetch,
+};
+
+// Graceful shutdown
+process.on("SIGTERM", () => {
+ console.log("[ifarted] SIGTERM received, shutting down gracefully");
+ process.exit(0);
+});
+
+process.on("SIGINT", () => {
+ console.log("[ifarted] SIGINT received, shutting down gracefully");
+ process.exit(0);
+});
+
+// For Node compatibility, also allow direct listen if run via tsx
+if (typeof Bun === "undefined") {
+ // @ts-ignore
+ const { serve } = await import("@hono/node-server").catch(() => ({ serve: null }));
+ if (serve) {
+ serve({ fetch: app.fetch, port });
+ console.log(`[ifarted] listening on http://localhost:${port} via @hono/node-server`);
+ } else {
+ console.log("[ifarted] @hono/node-server not installed, export fetch only");
+ }
+}
diff --git a/apps/server/src/lib/crypto.test.ts b/apps/server/src/lib/crypto.test.ts
new file mode 100644
index 00000000..3ba58d9b
--- /dev/null
+++ b/apps/server/src/lib/crypto.test.ts
@@ -0,0 +1,39 @@
+/**
+ * Unit tests for crypto lib — run with bun test
+ */
+
+import { describe, it, expect } from "bun:test";
+import { generateApiKey, hashApiKey, generateId, generateInviteCode, normalizePhoneE164 } from "./crypto.ts";
+
+describe("crypto", () => {
+ it("generateApiKey — 64 hex chars, 256-bit", () => {
+ const key = generateApiKey();
+ expect(key).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it("hashApiKey — deterministic SHA-256", async () => {
+ const key = "testkey123";
+ const hash1 = await hashApiKey(key);
+ const hash2 = await hashApiKey(key);
+ expect(hash1).toBe(hash2);
+ expect(hash1).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it("generateId — UUID v4", () => {
+ const id = generateId();
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
+ });
+
+ it("generateInviteCode — 8 chars, no O/0/I/1, unguessable", () => {
+ const code = generateInviteCode();
+ expect(code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/);
+ // Ensure no confusing chars
+ expect(code).not.toMatch(/[O0I1]/);
+ });
+
+ it("normalizePhoneE164 — keep + and digits", () => {
+ expect(normalizePhoneE164("(415) 555-2671")).toBe("4155552671");
+ expect(normalizePhoneE164("+1 (415) 555-2671")).toBe("+14155552671");
+ expect(normalizePhoneE164("+44 20 7123 4567")).toBe("+442071234567");
+ });
+});
diff --git a/apps/server/src/lib/crypto.ts b/apps/server/src/lib/crypto.ts
new file mode 100644
index 00000000..45ad5b32
--- /dev/null
+++ b/apps/server/src/lib/crypto.ts
@@ -0,0 +1,49 @@
+/**
+ * Crypto helpers — no PII leaks, secure tokens
+ * Uses Web Crypto where possible for Bun/Node compatibility
+ */
+
+export function generateApiKey(): string {
+ const bytes = new Uint8Array(32);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+export async function hashApiKey(apiKey: string): Promise {
+ const data = new TextEncoder().encode(apiKey);
+ const hash = await crypto.subtle.digest("SHA-256", data);
+ return Array.from(new Uint8Array(hash))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+export function generateId(): string {
+ return crypto.randomUUID();
+}
+
+export function generateInviteCode(): string {
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1
+ const bytes = new Uint8Array(8);
+ crypto.getRandomValues(bytes);
+ let code = "";
+ for (let i = 0; i < 8; i++) {
+ code += chars[bytes[i] % chars.length];
+ }
+ return code;
+}
+
+export function normalizePhoneE164(input: string): string {
+ // Very naive E.164 normalization — production should use libphonenumber
+ return input.replace(/[^+0-9]/g, "").trim();
+}
+
+export async function hashPhone(phoneE164: string): Promise {
+ const normalized = normalizePhoneE164(phoneE164);
+ const data = new TextEncoder().encode(normalized);
+ const hash = await crypto.subtle.digest("SHA-256", data);
+ return Array.from(new Uint8Array(hash))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
diff --git a/apps/server/src/lib/expo-push.ts b/apps/server/src/lib/expo-push.ts
new file mode 100644
index 00000000..0fdecf5a
--- /dev/null
+++ b/apps/server/src/lib/expo-push.ts
@@ -0,0 +1,80 @@
+/**
+ * Expo Push API relay — Bun calls Expo, Expo calls APNs/FCM
+ * Docs: https://docs.expo.dev/push-notifications/sending-notifications/
+ */
+
+import type { ExpoPushMessage, ExpoPushReceipt } from "@ifarted/contracts/src/index.ts";
+
+const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
+const MAX_BATCH = 100;
+
+export async function sendExpoPush(messages: ExpoPushMessage[]): Promise {
+ if (messages.length === 0) return [];
+
+ const receipts: ExpoPushReceipt[] = [];
+
+ for (let i = 0; i < messages.length; i += MAX_BATCH) {
+ const batch = messages.slice(i, i + MAX_BATCH);
+
+ const res = await fetch(EXPO_PUSH_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify(batch),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ console.error("[expo-push] failed", res.status, text);
+ // Push partial errors as receipts
+ for (let j = 0; j < batch.length; j++) {
+ receipts.push({
+ status: "error",
+ message: `HTTP ${res.status}: ${text.slice(0, 200)}`,
+ });
+ }
+ continue;
+ }
+
+ const json = (await res.json()) as { data: ExpoPushReceipt[] } | { errors: any[] };
+ if ("data" in json && Array.isArray(json.data)) {
+ receipts.push(...json.data);
+ } else if ("errors" in json) {
+ console.error("[expo-push] errors", json.errors);
+ for (let j = 0; j < batch.length; j++) {
+ receipts.push({ status: "error", message: JSON.stringify(json.errors).slice(0, 500) });
+ }
+ }
+ }
+
+ return receipts;
+}
+
+export function buildFartPushMessage(opts: {
+ to: `ExponentPushToken[${string}]`;
+ senderName: string;
+ messageId: string;
+ senderId: string;
+ lat?: number;
+ lng?: number;
+}): ExpoPushMessage {
+ const { to, senderName, messageId, senderId, lat, lng } = opts;
+ return {
+ to,
+ title: senderName,
+ body: "I farted.",
+ sound: "fart.caf", // iOS <30s, Android channel sound
+ data: {
+ type: "fart",
+ messageId,
+ senderId,
+ senderName,
+ lat,
+ lng,
+ sentAt: new Date().toISOString(),
+ },
+ channelId: "farts", // Android notification channel
+ };
+}
diff --git a/apps/server/src/lib/metrics.ts b/apps/server/src/lib/metrics.ts
new file mode 100644
index 00000000..c3bf3aa6
--- /dev/null
+++ b/apps/server/src/lib/metrics.ts
@@ -0,0 +1,45 @@
+/**
+ * Simple metrics for iFarted relay — in-memory for MVP, use Prometheus in prod
+ */
+
+interface Metrics {
+ totalUsers: number;
+ totalFarts: number;
+ totalInvites: number;
+ fartsLastHour: number;
+ activeUsersLastHour: number;
+}
+
+const metrics = {
+ farts: [] as { timestamp: number; senderId: string; recipientId: string }[],
+ users: new Set(),
+};
+
+export function recordFart(senderId: string, recipientId: string) {
+ metrics.farts.push({ timestamp: Date.now(), senderId, recipientId });
+ metrics.users.add(senderId);
+ metrics.users.add(recipientId);
+ // Keep only last 24h
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
+ metrics.farts = metrics.farts.filter((f) => f.timestamp > cutoff);
+}
+
+export function getMetrics(): Metrics {
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
+ const lastHour = metrics.farts.filter((f) => f.timestamp > oneHourAgo);
+ const activeUsers = new Set([...lastHour.map((f) => f.senderId), ...lastHour.map((f) => f.recipientId)]);
+
+ return {
+ totalUsers: metrics.users.size,
+ totalFarts: metrics.farts.length,
+ totalInvites: 0, // TODO: track invites
+ fartsLastHour: lastHour.length,
+ activeUsersLastHour: activeUsers.size,
+ };
+}
+
+// Cleanup every hour
+setInterval(() => {
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
+ metrics.farts = metrics.farts.filter((f) => f.timestamp > cutoff);
+}, 60 * 60 * 1000);
diff --git a/apps/server/src/lib/rate-limit-persistent.ts b/apps/server/src/lib/rate-limit-persistent.ts
new file mode 100644
index 00000000..799ff29c
--- /dev/null
+++ b/apps/server/src/lib/rate-limit-persistent.ts
@@ -0,0 +1,101 @@
+/**
+ * Persistent rate limiting using SQLite — production ready vs in-memory MVP
+ * Prevents Yo-style spam even after server restart
+ */
+
+import { getDb } from "../db/index.ts";
+
+interface RateLimitConfig {
+ max: number;
+ windowMs: number;
+}
+
+const LIMITS = {
+ sendPerHour: { max: 30, windowMs: 60 * 60 * 1000 },
+ perRecipientPerHour: { max: 20, windowMs: 60 * 60 * 1000 },
+};
+
+export async function checkRateLimitPersistent(
+ key: string,
+ limit: RateLimitConfig
+): Promise<{ allowed: boolean; retryAfterMs?: number; count?: number }> {
+ const db = await getDb();
+ const now = Date.now();
+ const windowStart = new Date(now - limit.windowMs).toISOString();
+
+ try {
+ // Count messages in window
+ let count = 0;
+ if (db.query) {
+ const row = db.query("SELECT COUNT(*) as count FROM messages WHERE sender_id = ? AND created_at > ?").get(key, windowStart) as any;
+ count = row?.count || 0;
+ } else {
+ const row = db.prepare("SELECT COUNT(*) as count FROM messages WHERE sender_id = ? AND created_at > ?").get(key, windowStart) as any;
+ count = row?.count || 0;
+ }
+
+ if (count >= limit.max) {
+ // Find oldest in window to calculate retryAfter
+ let oldest: any = null;
+ if (db.query) {
+ oldest = db.query("SELECT created_at FROM messages WHERE sender_id = ? AND created_at > ? ORDER BY created_at ASC LIMIT 1").get(key, windowStart) as any;
+ } else {
+ oldest = db.prepare("SELECT created_at FROM messages WHERE sender_id = ? AND created_at > ? ORDER BY created_at ASC LIMIT 1").get(key, windowStart) as any;
+ }
+ if (oldest) {
+ const oldestTime = new Date(oldest.created_at).getTime();
+ const retryAfterMs = oldestTime + limit.windowMs - now;
+ return { allowed: false, retryAfterMs: Math.max(0, retryAfterMs), count };
+ }
+ return { allowed: false, retryAfterMs: limit.windowMs, count };
+ }
+
+ return { allowed: true, count };
+ } catch (e) {
+ console.error("[rate-limit-persistent] db error", e);
+ // Fail open for MVP, but log
+ return { allowed: true, count: 0 };
+ }
+}
+
+export async function checkRateLimitFartPersistent(senderId: string, recipientId: string) {
+ // Check global per sender
+ const globalCheck = await checkRateLimitPersistent(senderId, LIMITS.sendPerHour);
+ if (!globalCheck.allowed) return globalCheck;
+
+ // Check per recipient — need custom query for sender+recipient
+ const db = await getDb();
+ const now = Date.now();
+ const windowStart = new Date(now - LIMITS.perRecipientPerHour.windowMs).toISOString();
+
+ try {
+ let count = 0;
+ if (db.query) {
+ const row = db.query("SELECT COUNT(*) as count FROM messages WHERE sender_id = ? AND recipient_id = ? AND created_at > ?").get(senderId, recipientId, windowStart) as any;
+ count = row?.count || 0;
+ } else {
+ const row = db.prepare("SELECT COUNT(*) as count FROM messages WHERE sender_id = ? AND recipient_id = ? AND created_at > ?").get(senderId, recipientId, windowStart) as any;
+ count = row?.count || 0;
+ }
+
+ if (count >= LIMITS.perRecipientPerHour.max) {
+ let oldest: any = null;
+ if (db.query) {
+ oldest = db.query("SELECT created_at FROM messages WHERE sender_id = ? AND recipient_id = ? AND created_at > ? ORDER BY created_at ASC LIMIT 1").get(senderId, recipientId, windowStart) as any;
+ } else {
+ oldest = db.prepare("SELECT created_at FROM messages WHERE sender_id = ? AND recipient_id = ? AND created_at > ? ORDER BY created_at ASC LIMIT 1").get(senderId, recipientId, windowStart) as any;
+ }
+ if (oldest) {
+ const oldestTime = new Date(oldest.created_at).getTime();
+ const retryAfterMs = oldestTime + LIMITS.perRecipientPerHour.windowMs - now;
+ return { allowed: false, retryAfterMs: Math.max(0, retryAfterMs), count };
+ }
+ return { allowed: false, retryAfterMs: LIMITS.perRecipientPerHour.windowMs, count };
+ }
+
+ return { allowed: true, count };
+ } catch (e) {
+ console.error("[rate-limit-persistent] per-recipient db error", e);
+ return { allowed: true, count: 0 };
+ }
+}
diff --git a/apps/server/src/lib/rate-limit.test.ts b/apps/server/src/lib/rate-limit.test.ts
new file mode 100644
index 00000000..1eb106fc
--- /dev/null
+++ b/apps/server/src/lib/rate-limit.test.ts
@@ -0,0 +1,27 @@
+/**
+ * Unit tests for rate limiting — in-memory version
+ */
+
+import { describe, it, expect, beforeEach } from "bun:test";
+import { checkRateLimit } from "./rate-limit.ts";
+
+describe("rate-limit", () => {
+ it("allows under limit", () => {
+ const key = `test-${Date.now()}-1`;
+ const limit = { max: 5, windowMs: 60 * 1000 };
+ for (let i = 0; i < 5; i++) {
+ const res = checkRateLimit(key, limit);
+ expect(res.allowed).toBe(true);
+ }
+ });
+
+ it("blocks over limit", () => {
+ const key = `test-${Date.now()}-2`;
+ const limit = { max: 2, windowMs: 60 * 1000 };
+ expect(checkRateLimit(key, limit).allowed).toBe(true);
+ expect(checkRateLimit(key, limit).allowed).toBe(true);
+ const blocked = checkRateLimit(key, limit);
+ expect(blocked.allowed).toBe(false);
+ expect(blocked.retryAfterMs).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/server/src/lib/rate-limit.ts b/apps/server/src/lib/rate-limit.ts
new file mode 100644
index 00000000..16ea5dcb
--- /dev/null
+++ b/apps/server/src/lib/rate-limit.ts
@@ -0,0 +1,58 @@
+/**
+ * Simple in-memory rate limiter — production would use Redis or SQLite counters
+ * Prevents Yo-style spam (2014 hack)
+ */
+
+type Key = string;
+
+interface Bucket {
+ count: number;
+ resetAt: number;
+}
+
+const buckets = new Map();
+
+const LIMITS = {
+ // Per sender: 30 farts per hour
+ sendPerHour: { max: 30, windowMs: 60 * 60 * 1000 },
+ // Per recipient: max 20 farts per hour from same sender (anti-harassment)
+ perRecipientPerHour: { max: 20, windowMs: 60 * 60 * 1000 },
+ // Global: 100 requests per minute per IP
+ globalPerMinute: { max: 100, windowMs: 60 * 1000 },
+};
+
+export function checkRateLimit(key: Key, limit: { max: number; windowMs: number }): { allowed: boolean; retryAfterMs?: number } {
+ const now = Date.now();
+ const bucket = buckets.get(key);
+
+ if (!bucket || now > bucket.resetAt) {
+ buckets.set(key, { count: 1, resetAt: now + limit.windowMs });
+ return { allowed: true };
+ }
+
+ if (bucket.count < limit.max) {
+ bucket.count++;
+ return { allowed: true };
+ }
+
+ return { allowed: false, retryAfterMs: bucket.resetAt - now };
+}
+
+export function rateLimitFart(senderId: string, recipientId: string) {
+ const globalKey = `send:${senderId}`;
+ const perRecipientKey = `send:${senderId}:${recipientId}`;
+
+ const globalCheck = checkRateLimit(globalKey, LIMITS.sendPerHour);
+ if (!globalCheck.allowed) return globalCheck;
+
+ const recipientCheck = checkRateLimit(perRecipientKey, LIMITS.perRecipientPerHour);
+ return recipientCheck;
+}
+
+// Cleanup old buckets every 5 min
+setInterval(() => {
+ const now = Date.now();
+ for (const [k, v] of buckets) {
+ if (now > v.resetAt) buckets.delete(k);
+ }
+}, 5 * 60 * 1000);
diff --git a/apps/server/src/lib/sounds.ts b/apps/server/src/lib/sounds.ts
new file mode 100644
index 00000000..37415e69
--- /dev/null
+++ b/apps/server/src/lib/sounds.ts
@@ -0,0 +1,33 @@
+/**
+ * Sound library for iFarted — multiple fart variants
+ * For MVP, single fart.caf, but we can have multiple variants for fun
+ * In production, these would be real audio files, but we can generate metadata
+ */
+
+export interface FartSound {
+ id: string;
+ name: string;
+ file: string; // e.g., fart.caf, fart2.caf, etc.
+ durationMs: number;
+ description: string;
+}
+
+export const FART_SOUNDS: FartSound[] = [
+ { id: "classic", name: "Classic", file: "fart.caf", durationMs: 1200, description: "The OG — brown noise + sine sweep, deadpan" },
+ { id: "short", name: "Short & Sweet", file: "fart_short.caf", durationMs: 400, description: "Quick puff, like a Yo but fartier" },
+ { id: "long", name: "Long Rumble", file: "fart_long.caf", durationMs: 2500, description: "Extended, for when context demands emphasis" },
+ { id: "squeaky", name: "Squeaky", file: "fart_squeaky.caf", durationMs: 800, description: "High-pitched, cartoonish" },
+ { id: "wet", name: "Wet", file: "fart_wet.caf", durationMs: 1500, description: "Don't ask, you know what it means" },
+];
+
+export function getRandomFartSound(): FartSound {
+ return FART_SOUNDS[Math.floor(Math.random() * FART_SOUNDS.length)];
+}
+
+export function getFartSoundById(id: string): FartSound | undefined {
+ return FART_SOUNDS.find(s => s.id === id);
+}
+
+export function getDefaultFartSound(): FartSound {
+ return FART_SOUNDS[0];
+}
diff --git a/apps/server/src/lib/websocket.ts b/apps/server/src/lib/websocket.ts
new file mode 100644
index 00000000..798df2ad
--- /dev/null
+++ b/apps/server/src/lib/websocket.ts
@@ -0,0 +1,72 @@
+/**
+ * WebSocket support for iFarted — real-time fart delivery status (optional, for web demo)
+ * For MVP, push is via Expo Push API, but WebSocket can provide instant feedback in web client
+ */
+
+import { Hono } from "hono";
+
+const wsApp = new Hono();
+
+interface Client {
+ userId: string;
+ ws: any;
+}
+
+const clients = new Map>();
+
+export function addClient(userId: string, ws: any) {
+ if (!clients.has(userId)) {
+ clients.set(userId, new Set());
+ }
+ clients.get(userId)!.add({ userId, ws });
+ console.log(`[ws] client added for ${userId}, total ${clients.get(userId)!.size}`);
+}
+
+export function removeClient(userId: string, ws: any) {
+ const set = clients.get(userId);
+ if (set) {
+ for (const client of set) {
+ if (client.ws === ws) {
+ set.delete(client);
+ console.log(`[ws] client removed for ${userId}, remaining ${set.size}`);
+ break;
+ }
+ }
+ if (set.size === 0) {
+ clients.delete(userId);
+ }
+ }
+}
+
+export function notifyFart(recipientId: string, payload: any) {
+ const set = clients.get(recipientId);
+ if (set) {
+ for (const client of set) {
+ try {
+ client.ws.send(JSON.stringify({ type: "fart", payload }));
+ } catch (e) {
+ console.warn(`[ws] send failed for ${recipientId}`, e);
+ }
+ }
+ }
+}
+
+wsApp.get("/", (c) => {
+ const userId = c.req.query("userId");
+ if (!userId) {
+ return c.json({ error: "userId query required" }, 400);
+ }
+
+ // For Bun, upgrade to WebSocket
+ // @ts-ignore - Bun specific
+ if (typeof Bun !== "undefined" && c.req.header("upgrade") === "websocket") {
+ // @ts-ignore
+ const { response, socket } = Bun.Transpiler ? { response: null, socket: null } : { response: null, socket: null };
+ // Simplified: use Hono's websocket helper if available, otherwise fallback
+ return c.json({ error: "WebSocket upgrade not implemented in this Hono version, use ws library" }, 501);
+ }
+
+ return c.json({ ok: true, message: "WebSocket endpoint — connect with userId query, then receive fart notifications in real-time (optional, for web demo)" });
+});
+
+export default wsApp;
diff --git a/apps/server/src/load-test.ts b/apps/server/src/load-test.ts
new file mode 100644
index 00000000..eea0a25c
--- /dev/null
+++ b/apps/server/src/load-test.ts
@@ -0,0 +1,119 @@
+/**
+ * Load test for iFarted relay — simulates many users sending farts
+ * Run: bun src/load-test.ts
+ * Env: API_URL, CONCURRENT_USERS, FARTS_PER_USER
+ */
+
+const API_URL = process.env.API_URL || "http://localhost:3000";
+const CONCURRENT_USERS = Number(process.env.CONCURRENT_USERS || 10);
+const FARTS_PER_USER = Number(process.env.FARTS_PER_USER || 5);
+
+async function api(path: string, opts: any = {}) {
+ const res = await fetch(`${API_URL}${path}`, {
+ ...opts,
+ headers: {
+ "Content-Type": "application/json",
+ ...(opts.headers || {}),
+ },
+ });
+ const text = await res.text();
+ let json: any;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ json = text;
+ }
+ if (!res.ok) {
+ throw new Error(`API ${path} ${res.status}: ${JSON.stringify(json).slice(0, 200)}`);
+ }
+ return json;
+}
+
+async function registerUser(username: string) {
+ return api("/v1/register", {
+ method: "POST",
+ body: JSON.stringify({ username, displayName: username }),
+ });
+}
+
+async function sendFart(apiKey: string, recipientId: string) {
+ return api("/v1/farts", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({ recipientId }),
+ });
+}
+
+async function main() {
+ console.log(`🔥 Load test against ${API_URL} — ${CONCURRENT_USERS} users, ${FARTS_PER_USER} farts each`);
+
+ // Register users
+ const users = [];
+ const base = Date.now().toString().slice(-6);
+ for (let i = 0; i < CONCURRENT_USERS; i++) {
+ const username = `lt${base}${i}`.slice(0, 20);
+ const user = await registerUser(username);
+ users.push(user);
+ // Add friends to each other (first user is friends with all)
+ if (i > 0) {
+ await api("/v1/friends", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${users[0].apiKey}` },
+ body: JSON.stringify({ userId: user.userId, via: "username" }),
+ });
+ await api("/v1/friends", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${user.apiKey}` },
+ body: JSON.stringify({ userId: users[0].userId, via: "username" }),
+ });
+ }
+ }
+ console.log(`✅ Registered ${users.length} users, first user friends with all`);
+
+ // Send farts concurrently
+ const start = Date.now();
+ let success = 0;
+ let rateLimited = 0;
+ let errors = 0;
+
+ const promises = [];
+ for (let i = 0; i < CONCURRENT_USERS; i++) {
+ for (let j = 0; j < FARTS_PER_USER; j++) {
+ const sender = users[i];
+ const recipient = users[(i + 1) % users.length];
+ promises.push(
+ sendFart(sender.apiKey, recipient.userId)
+ .then(() => success++)
+ .catch((e) => {
+ if (e.message.includes("429") || e.message.includes("rate limited")) {
+ rateLimited++;
+ } else {
+ errors++;
+ console.warn(`Fart failed: ${e.message.slice(0, 100)}`);
+ }
+ })
+ );
+ }
+ }
+
+ await Promise.all(promises);
+ const elapsed = (Date.now() - start) / 1000;
+
+ console.log(`\n📊 Load test results:`);
+ console.log(` Total attempts: ${CONCURRENT_USERS * FARTS_PER_USER}`);
+ console.log(` Success: ${success}`);
+ console.log(` Rate limited: ${rateLimited} (expected if over 30/hour per sender)`);
+ console.log(` Errors: ${errors}`);
+ console.log(` Elapsed: ${elapsed.toFixed(2)}s`);
+ console.log(` RPS: ${(success / elapsed).toFixed(2)}`);
+
+ const metrics = await api("/metrics");
+ console.log(`\n📈 Metrics after load:`, metrics);
+
+ console.log(`\n✅ Load test complete`);
+}
+
+main().catch((e) => {
+ console.error("❌ Load test failed", e);
+ process.exit(1);
+});
diff --git a/apps/server/src/test.ts b/apps/server/src/test.ts
new file mode 100644
index 00000000..e871dd29
--- /dev/null
+++ b/apps/server/src/test.ts
@@ -0,0 +1,118 @@
+/**
+ * Simple integration test for iFarted relay
+ * Run: bun src/test.ts (server must be running on :3000 or set API_URL)
+ */
+
+const API_URL = process.env.API_URL || "http://localhost:3000";
+
+async function api(path: string, opts: any = {}) {
+ const res = await fetch(`${API_URL}${path}`, {
+ ...opts,
+ headers: {
+ "Content-Type": "application/json",
+ ...(opts.headers || {}),
+ },
+ });
+ const text = await res.text();
+ let json: any;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ json = text;
+ }
+ if (!res.ok) {
+ throw new Error(`API ${path} ${res.status}: ${JSON.stringify(json).slice(0, 500)}`);
+ }
+ return json;
+}
+
+async function main() {
+ console.log(`Testing against ${API_URL}`);
+
+ // Register two users
+ const user1 = await api("/v1/register", {
+ method: "POST",
+ body: JSON.stringify({ username: `test_${Date.now()}_1`, displayName: "Test One" }),
+ });
+ console.log("user1", user1.userId, user1.user.username);
+
+ const user2 = await api("/v1/register", {
+ method: "POST",
+ body: JSON.stringify({ username: `test_${Date.now()}_2`, displayName: "Test Two" }),
+ });
+ console.log("user2", user2.userId, user2.user.username);
+
+ // Search
+ const search = await api(`/v1/users/search?username=${user2.user.username.slice(0, 4)}`, {
+ headers: { Authorization: `Bearer ${user1.apiKey}` },
+ });
+ console.log("search", search);
+
+ // Add friend
+ await api("/v1/friends", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${user1.apiKey}` },
+ body: JSON.stringify({ userId: user2.userId, via: "username" }),
+ });
+ console.log("add friend ok");
+
+ // Friends list
+ const friends = await api("/v1/friends", {
+ headers: { Authorization: `Bearer ${user1.apiKey}` },
+ });
+ console.log("friends", friends);
+
+ // Register fake push token for user2
+ await api("/v1/tokens", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${user2.apiKey}` },
+ body: JSON.stringify({ expoPushToken: "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", platform: "ios" }),
+ });
+ console.log("token registered");
+
+ // Send fart
+ const fart = await api("/v1/farts", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${user1.apiKey}` },
+ body: JSON.stringify({ recipientId: user2.userId, lat: 37.7749, lng: -122.4194 }),
+ });
+ console.log("fart sent", fart);
+
+ // Create invite
+ const invite = await api("/v1/invites", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${user1.apiKey}` },
+ });
+ console.log("invite", invite);
+
+ // Toggle phone discovery
+ await api("/v1/settings/phone-discovery", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${user1.apiKey}` },
+ body: JSON.stringify({ enabled: true }),
+ });
+ console.log("phone discovery enabled");
+
+ // Block
+ await api("/v1/block", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${user1.apiKey}` },
+ body: JSON.stringify({ userId: user2.userId }),
+ });
+ console.log("block ok");
+
+ // Unblock
+ await api("/v1/unblock", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${user1.apiKey}` },
+ body: JSON.stringify({ userId: user2.userId }),
+ });
+ console.log("unblock ok");
+
+ console.log("✅ All tests passed");
+}
+
+main().catch((e) => {
+ console.error("❌ Test failed", e);
+ process.exit(1);
+});
diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json
new file mode 100644
index 00000000..0accbe93
--- /dev/null
+++ b/apps/server/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["bun"]
+ },
+ "include": ["src"]
+}
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 00000000..e66cf81e
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ iFarted — Web Demo
+
+
+
+
+
+
+
+
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 00000000..407f7d1c
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@ifarted/web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0 --port 5174",
+ "build": "vite build",
+ "preview": "vite preview --host 0.0.0.0 --port 4174",
+ "lint": "tsc --noEmit"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "typescript": "^5.5.0",
+ "vite": "^5.2.0"
+ }
+}
diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png
new file mode 100644
index 00000000..ffb127b6
Binary files /dev/null and b/apps/web/public/icon-192.png differ
diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png
new file mode 100644
index 00000000..ffb127b6
Binary files /dev/null and b/apps/web/public/icon-512.png differ
diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json
new file mode 100644
index 00000000..27087719
--- /dev/null
+++ b/apps/web/public/manifest.json
@@ -0,0 +1,21 @@
+{
+ "name": "iFarted — Web Demo",
+ "short_name": "iFarted",
+ "description": "Send a friend exactly one thing: \"I farted.\" — context-based messaging, Yo-style",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#fff7ed",
+ "theme_color": "#000000",
+ "icons": [
+ {
+ "src": "/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx
new file mode 100644
index 00000000..6e24e711
--- /dev/null
+++ b/apps/web/src/App.jsx
@@ -0,0 +1,227 @@
+import { useState, useEffect } from 'react';
+
+const API_URL = import.meta.env.VITE_IFARTED_API_URL || 'http://localhost:3000';
+
+export default function App() {
+ const [apiKey, setApiKey] = useState(localStorage.getItem('ifarted_apiKey') || '');
+ const [userId, setUserId] = useState(localStorage.getItem('ifarted_userId') || '');
+ const [username, setUsername] = useState('');
+ const [friends, setFriends] = useState([]);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [log, setLog] = useState([]);
+ const [metrics, setMetrics] = useState(null);
+ const [serverStatus, setServerStatus] = useState('checking');
+ const [sending, setSending] = useState(null);
+ const [inviteCode, setInviteCode] = useState('');
+ const [inviteLink, setInviteLink] = useState('');
+ const [isDark, setIsDark] = useState(() => localStorage.getItem('ifarted_darkMode') === 'true');
+
+ useEffect(() => {
+ localStorage.setItem('ifarted_darkMode', isDark.toString());
+ }, [isDark]);
+
+ useEffect(() => {
+ fetch(`${API_URL}/health`)
+ .then(r => r.json())
+ .then(() => {
+ setServerStatus('online');
+ fetch(`${API_URL}/metrics`).then(r => r.json()).then(setMetrics).catch(()=>{});
+ })
+ .catch(() => setServerStatus('offline'));
+ }, []);
+
+ useEffect(() => {
+ if (!apiKey) return;
+ fetch(`${API_URL}/v1/friends`, { headers: { Authorization: `Bearer ${apiKey}` } })
+ .then(r => r.json())
+ .then(d => setFriends(d.friends || []))
+ .catch(()=>{});
+ }, [apiKey]);
+
+ const addLog = (msg) => setLog(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 10));
+
+ const register = async () => {
+ if (!username) return alert('Enter username');
+ try {
+ const res = await fetch(`${API_URL}/v1/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, displayName: username, inviteCode: inviteCode || undefined }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error);
+ setApiKey(data.apiKey);
+ setUserId(data.userId);
+ localStorage.setItem('ifarted_apiKey', data.apiKey);
+ localStorage.setItem('ifarted_userId', data.userId);
+ addLog(`✅ Registered @${data.user.username} — id ${data.userId.slice(0,8)}...`);
+ } catch (e) {
+ alert(e.message);
+ }
+ };
+
+ const search = async () => {
+ if (!apiKey || !searchQuery) return;
+ const res = await fetch(`${API_URL}/v1/users/search?username=${encodeURIComponent(searchQuery)}`, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ });
+ const data = await res.json();
+ if (res.ok) setSearchResults(data);
+ else alert(data.error);
+ };
+
+ const addFriend = async (fid) => {
+ if (!apiKey) return alert('Register first');
+ const res = await fetch(`${API_URL}/v1/friends`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({ userId: fid, via: 'username' }),
+ });
+ const data = await res.json();
+ if (!res.ok) return alert(data.error);
+ addLog(`✅ Added friend ${fid.slice(0,8)}...`);
+ const rf = await fetch(`${API_URL}/v1/friends`, { headers: { Authorization: `Bearer ${apiKey}` } }).then(r=>r.json());
+ setFriends(rf.friends || []);
+ };
+
+ const sendFart = async (friend) => {
+ setSending(friend.id);
+ addLog(`💨 Fart sent to @${friend.username} — "I farted."`);
+ if (apiKey) {
+ try {
+ const res = await fetch(`${API_URL}/v1/farts`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({ recipientId: friend.id, lat: 37.7749, lng: -122.4194 }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ addLog(`✅ Server: ${data.messageId.slice(0,8)}... ${data.warning || ''}`);
+ fetch(`${API_URL}/metrics`).then(r=>r.json()).then(setMetrics).catch(()=>{});
+ } else {
+ addLog(`❌ ${data.error}`);
+ }
+ } catch {
+ addLog(`(mock) Push via Expo Push API → APNs/FCM`);
+ }
+ }
+ try {
+ const audio = new Audio('/fart.mp3');
+ audio.volume = 0.5;
+ audio.play().catch(()=>{});
+ } catch {}
+ setTimeout(()=>setSending(null), 800);
+ };
+
+ const createInvite = async () => {
+ if (!apiKey) return alert('Register first');
+ const res = await fetch(`${API_URL}/v1/invites`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${apiKey}` },
+ });
+ const data = await res.json();
+ if (res.ok) {
+ setInviteCode(data.code);
+ setInviteLink(data.inviteLink);
+ addLog(`✅ Invite created: ${data.code} — ${data.inviteLink}`);
+ } else alert(data.error);
+ };
+
+ const mockFriends = [
+ { id: '1', username: 'alex', displayName: 'Alex', addedVia: 'mock', lastFartAt: null },
+ { id: '2', username: 'sam', displayName: 'Sam', addedVia: 'mock', lastFartAt: null },
+ ];
+
+ const displayFriends = friends.length > 0 ? friends : mockFriends;
+
+ const bg = isDark ? '#1a1a1a' : '#fff7ed';
+ const cardBg = isDark ? '#2a2a2a' : '#fff';
+ const text = isDark ? '#fff' : '#000';
+ const subText = isDark ? '#aaa' : '#666';
+
+ return (
+
+
+ 💨 iFarted — Web Demo
+
+
+ Dead-simple Yo-style: "I farted." is the entire message. No typing, no inbox, notification IS message. Server: {serverStatus} {metrics && `· ${metrics.totalFarts} farts, ${metrics.totalUsers} users, ${metrics.fartsLastHour}/hour`}
+
+
+
+ Register
+
+ setUsername(e.target.value)} placeholder="username" style={{ flex: 1, padding: 8, borderRadius: 8, border: '1px solid #ddd' }} />
+
+
+ {apiKey && ✅ {userId.slice(0,8)}... {apiKey.slice(0,8)}... (localStorage)
}
+
+ Search @username
+
+ setSearchQuery(e.target.value)} placeholder="alex" style={{ flex: 1, padding: 8, borderRadius: 8, border: '1px solid #ddd' }} />
+
+
+ {searchResults.map(u => (
+
+ @{u.username}
+
+
+ ))}
+
+ Invite Code
+
+ {inviteCode && {inviteCode} — {inviteLink}
}
+
+ Log
+
+ {log.map((l,i)=>{l})}
+ {log.length===0 && No logs yet. Register → search → add friend → tap 💨 Fart}
+
+
+
+
+ Home — Tap to Fart
+ Recipient list ordered by most-recently active (Yo-style). No inbox/history. {displayFriends.length} friends
+
+ {displayFriends.map(f => (
+
+
+ {f.displayName || f.username}
+ @{f.username} · {f.addedVia} {f.lastFartAt ? `· last ${new Date(f.lastFartAt).toLocaleTimeString()}` : ''}
+
+
+
+ ))}
+
+ Context-based messaging: "You understand by the context what is being said." — Or Arbel (Yo creator). One phrase, meaning from context.
+
+ AdMob Banner — Remove Ads in Settings ($1.99) · Non-personalized
+
+
+
+
+
+ Architecture
+ {`[Sender — Web/Mobile] POST /v1/farts {recipientId, lat?, lng?} (Bearer apiKey)
+ ↓
+[Bun relay] auth + rate limit → insert message → recordFart() → call Expo Push API:
+ POST https://exp.host/--/api/v2/push/send {to, title=senderName, body="I farted.", sound="fart.caf", data:{...}}
+ ↓
+[Expo Push Service] → [APNs / FCM]
+ ↓
+[Recipient] OS notification (title=senderName, body="I farted.", sound=fart.caf) → tap → fart-detail + map pin + fart back`}
+ No inbox/history — notification IS message. Messages table kept only for rate limiting/abuse. Thin client, thin backend.
+
+
+
+ iFarted v0.9.0 Alpha — Scaffold v10 — Server live :3000 — Web demo 5174 — UDL site 5173
+ Drive folder 18r18wIm0ftoZ1Pq-l2g2MddqCf17sxsX · GitHub ifarted · Memory Bank 6 core + research
+
+
+ );
+}
diff --git a/apps/web/src/components/AdminDashboard.jsx b/apps/web/src/components/AdminDashboard.jsx
new file mode 100644
index 00000000..f0c131e0
--- /dev/null
+++ b/apps/web/src/components/AdminDashboard.jsx
@@ -0,0 +1,110 @@
+import { useState, useEffect } from 'react';
+
+const API_URL = import.meta.env.VITE_IFARTED_API_URL || 'http://localhost:3000';
+
+export default function AdminDashboard() {
+ const [adminKey, setAdminKey] = useState(localStorage.getItem('ifarted_adminKey') || '');
+ const [data, setData] = useState(null);
+ const [users, setUsers] = useState([]);
+ const [farts, setFarts] = useState([]);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ if (adminKey) localStorage.setItem('ifarted_adminKey', adminKey);
+ }, [adminKey]);
+
+ const load = async () => {
+ if (!adminKey) return setError('Enter ADMIN_KEY');
+ setError('');
+ try {
+ const res = await fetch(`${API_URL}/admin?key=${encodeURIComponent(adminKey)}`);
+ const json = await res.json();
+ if (!res.ok) throw new Error(json.error || 'Failed');
+ setData(json);
+
+ const uRes = await fetch(`${API_URL}/admin/users?key=${encodeURIComponent(adminKey)}`);
+ const uJson = await uRes.json();
+ if (uRes.ok) setUsers(uJson.users || []);
+
+ const fRes = await fetch(`${API_URL}/admin/farts?key=${encodeURIComponent(adminKey)}`);
+ const fJson = await fRes.json();
+ if (fRes.ok) setFarts(fJson.farts || []);
+ } catch (e) {
+ setError(e.message);
+ }
+ };
+
+ useEffect(() => {
+ if (adminKey) load();
+ }, []);
+
+ return (
+
+ 💨 iFarted — Admin Dashboard
+ Metrics + users + farts · Protected by ADMIN_KEY (query or x-admin-key header)
+
+
+ setAdminKey(e.target.value)}
+ placeholder="ADMIN_KEY (test123 for local)"
+ style={{ flex: 1, padding: 8, borderRadius: 8, border: '1px solid #ddd' }}
+ />
+
+
+
+ {error && {error}
}
+
+ {data && (
+ <>
+
+
+ Total Users
+ {data.counts?.users ?? data.totalUsers}
+
+
+ Total Farts
+ {data.counts?.farts ?? data.totalFarts}
+
+
+ Farts / Hour
+ {data.metrics?.fartsLastHour ?? data.fartsLastHour}
+
+
+ Active Users / Hour
+ {data.metrics?.activeUsersLastHour ?? data.activeUsersLastHour}
+
+
+
+
+ Raw JSON
+ {JSON.stringify(data, null, 2)}
+
+
+
+
+ Recent Users (20)
+
+ {users.slice(0,20).map(u => (
+
+ @{u.username} — {u.id.slice(0,8)}... {u.display_name || ''} {u.created_at}
+
+ ))}
+
+
+
+ Recent Farts (20)
+
+ {farts.slice(0,20).map(f => (
+
+ {f.id.slice(0,8)}... {f.sender_id.slice(0,6)}→{f.recipient_id.slice(0,6)} {f.lat ? `${f.lat.toFixed(2)},${f.lng?.toFixed(2)}` : 'no loc'} {f.created_at}
+
+ ))}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/SoundPicker.jsx b/apps/web/src/components/SoundPicker.jsx
new file mode 100644
index 00000000..7129010f
--- /dev/null
+++ b/apps/web/src/components/SoundPicker.jsx
@@ -0,0 +1,44 @@
+import { useState } from 'react';
+
+const SOUNDS = [
+ { id: 'classic', name: 'Classic', file: '/fart.mp3', duration: 1200, desc: 'OG brown noise + sine sweep' },
+ { id: 'short', name: 'Short', file: '/fart.mp3', duration: 400, desc: 'Quick puff' },
+ { id: 'long', name: 'Long Rumble', file: '/fart.mp3', duration: 2500, desc: 'Extended emphasis' },
+ { id: 'squeaky', name: 'Squeaky', file: '/fart.mp3', duration: 800, desc: 'Cartoonish' },
+ { id: 'wet', name: 'Wet', file: '/fart.mp3', duration: 1500, desc: "Don't ask" },
+];
+
+export default function SoundPicker({ selected, onSelect }) {
+ const [playing, setPlaying] = useState(null);
+
+ const play = (s) => {
+ setPlaying(s.id);
+ try {
+ const audio = new Audio(s.file);
+ audio.volume = 0.5;
+ audio.play().catch(()=>{});
+ setTimeout(()=>setPlaying(null), s.duration);
+ } catch {
+ setPlaying(null);
+ }
+ };
+
+ return (
+
+ 🔊 Sound Picker (v11)
+ Choose your fart — classic is default, others for context emphasis
+ {SOUNDS.map(s => (
+
+
+ {s.name} {selected===s.id && '✅'}
+ {s.desc} · {s.duration}ms
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
new file mode 100644
index 00000000..b6bf8992
--- /dev/null
+++ b/apps/web/src/index.css
@@ -0,0 +1,4 @@
+* { box-sizing: border-box; }
+body { margin: 0; background: #fff7ed; color: #000; }
+button { cursor: pointer; }
+input { font-family: inherit; }
diff --git a/apps/web/src/main.jsx b/apps/web/src/main.jsx
new file mode 100644
index 00000000..7497ae86
--- /dev/null
+++ b/apps/web/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App.jsx';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+);
diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js
new file mode 100644
index 00000000..d089a672
--- /dev/null
+++ b/apps/web/vite.config.js
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: '0.0.0.0',
+ port: 5174,
+ },
+ preview: {
+ host: '0.0.0.0',
+ port: 4174,
+ },
+});
diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md
new file mode 100644
index 00000000..78293e68
--- /dev/null
+++ b/memory-bank/activeContext.md
@@ -0,0 +1,49 @@
+# Active Context — iFarted
+
+*Last updated: 2026-09-11 (session 5 ifarted) — scaffold v3 complete*
+
+## Current State
+- **Scaffold v3 complete — audio assets generated, icons generated, AdMob/IAP real wiring, privacy manifest, UDL website integration with web demo, CI, deployment guide**
+- **Stack (locked):** React Native + Expo + TypeScript + Bun + Hono + SQLite + Expo Push API + AdMob + RevenueCat
+- **Workspace**: `.clinerules/`, `memory-bank/` (6 core + research), `apps/mobile` (7 screens + 5 libs + 3 components), `apps/server` (11 endpoints + test + Dockerfile + README), `packages/contracts`, `src/components/IFarted/` (web demo), `README.md`, `DEPLOYMENT.md`, `IMPORT_NOTES.md`, `.github/workflows/ifarted.yml` — plus original UDL book `src/` (Vite site builds 133 modules 309KB)
+- **Decisions locked:**
+ 1. **Identity & discovery = all three:** unique @username + search · phone/contacts (opt-in) · invite code/deep link — implemented: search.tsx, contacts.tsx (hashed, discovery-only), invite.tsx (code+deep link+share)
+ 2. **Backend = lightweight Bun + Expo Push API** — **Bun 1.4.2 via npm** (bun.sh TLS blocked, workaround via `npm install -g bun`). Server runs on :3000, DB migrated, 11 endpoints, rate limiting, integration test passes.
+ 3. **Product design = Yo! pattern** — fixed phrase, notification text+audio, contact-list home with tap-to-send + one-tap fart back, ephemeral, context-based messaging framing.
+ 4. **Audio asset (closed v3):** generated placeholder fart.wav 1.2s (brown noise + sine sweep down 200→40Hz, envelope) copied to .caf/.mp3 + android raw, <30s for iOS, TODO pro sound final
+ 5. **Branding (closed v3 placeholder):** icons generated via AI (icon.png/adaptive-icon.png/splash.png minimalist black bubble 💨), TODO pro final
+ 6. **Ads + IAP (closed v3 wiring):** AdMob real BannerAd + fallback, non-personalized, single gated component, IAP RevenueCat favored + expo-iap fallback, $1.99 suggestion, entitlement ad_free, product remove_ads
+
+## Recent Changes (ifarted)
+- 2026-09-09 (session 3): **Repo initialized & pushed.** Server repo `2re/iFarted` already existed (public/open, created 2026-09-09 13:03Z). Local: `git init -b main`, added `README.md` + `.gitignore`, committed everything, initial commit `8fec909`. Pushed via SSH key (`torrey@nommesen.com`).
+- 2026-09-09 (session 2): Researched Yo! Created `memory-bank/research/yo-app.md`. Answered Bun feasibility (yes). Locked identity, backend, product model.
+- 2026-09-11 (import): **Imported into udlbook ifarted** via Google Drive folder 18r18wIm0ftoZ1Pq-l2g2MddqCf17sxsX using embeddedfolderview ID extraction workaround (drive.google.com TLS blocked, fetch_page proxy used).
+- 2026-09-11 (scaffold v1): Monorepo scaffold: contracts, server (Hono+Bun+SQLite), mobile (4 screens). Server tested.
+- 2026-09-11 (scaffold v2): Bun 1.4.2 via npm, server v2 with 11 endpoints (me, friends, add friend, phone-discovery toggle, unblock), mobile v2 with 7 screens + Zustand friends store + notifications lib (channel+token+listeners) + contacts lib + AdBanner gated + FartButton + EmptyState. Integration test `bun src/test.ts` passes. UDL site still builds. Relay server live on :3000.
+- 2026-09-11 (scaffold v3): Audio assets generated (fart.wav 1.2s brown noise + sine sweep down, copied to .caf/.mp3 + android raw), icons generated (icon.png/adaptive/splash minimalist bubble 💨), AdBanner real wiring with BannerAd fallback, IAP real wiring RevenueCat + expo-iap ($1.99 suggestion, entitlement ad_free), PrivacyInfo.xcprivacy, UDL website integration: new IFartedSection component with demo box (tap-to-fart + log + sound + real API try), Navbar + Sidebar links, public/fart assets, .github/workflows/ifarted.yml CI (server+mobile+udl), DEPLOYMENT.md (Docker/Fly.io/Railway/EAS), STORE_CHECKLIST expanded. Server live v2, UDL build 133 modules 309KB, all tests pass.
+- 2026-09-11 (scaffold v3 continued): Web demo improved, CI added, deployment guide, README updated, sound + icons committed, AdMob/IAP libs improved, privacy manifest, progress/activeContext updated to v3.
+
+## Remaining Open Decisions (v3 — mostly closed, only final polish left)
+1. ~~Audio asset~~ ✅ placeholder generated, TODO pro sound final (<30s, on-brand not too gross)
+2. ~~Remove Ads price/lib~~ ✅ $1.99 + RevenueCat favored wired, TODO create products in stores
+3. ~~Ad placement~~ ✅ banner on home default, single gated, non-personalized, TODO confirm no interstitial
+4. ~~Branding~~ ✅ placeholder icons generated, TODO pro icon + screenshots + store copy
+5. ~~Deploy target~~ ✅ documented Fly.io/Railway/VPS + Docker + EAS, TODO choose final + domain ifarted.app + api.ifarted.app
+
+## Next Steps
+1. ~~Repo setup~~ ✅ done
+2. ~~Scaffold monorepo~~ ✅ done v1+v2+v3
+3. ~~Install Bun + relay server~~ ✅ done — Bun 1.4.2 via npm, 11 endpoints, test passes, live :3000
+4. ~~Client screens~~ ✅ done — 7 screens + stores + libs + components
+5. ~~Audio + Ads + IAP wiring + privacy + UDL integration~~ ✅ done v3
+6. Device-to-device E2E with 2 EAS dev builds + real Expo push tokens + final sound asset ← **NEXT** (needs real devices)
+7. Alpha on real devices both platforms
+8. Store assets final (pro icon, screenshots, copy) + compliance review → TestFlight + Play internal
+
+## Important Patterns / Preferences to Preserve
+- Tiny, single-purpose product — **resist feature creep**; Yo research (research/yo-app.md) is reference for "does this serve fart notification?"
+- Ads first, Remove Ads IAP second; single gated ad component
+- Expo managed workflow + config plugins; app.json source of truth
+- Server code stays Bun **and** Node-runnable
+- One TS codebase for both stores; platform differences only where push/permissions/sound demand it
+- Context-based messaging framing for App Review: "You understand by the context what is being said." — Or Arbel
diff --git a/memory-bank/productContext.md b/memory-bank/productContext.md
new file mode 100644
index 00000000..fe8c7ec8
--- /dev/null
+++ b/memory-bank/productContext.md
@@ -0,0 +1,40 @@
+# Product Context — iFarted
+
+## Why This Project Exists
+Pure-play comedic utility. The product *is* the punchline: a friend's phone lights up with a deadpan push notification reading **"I farted."** There is no feed, no inbox, no typing — the notification itself is the entire message. This is **Yo!** (2014) with flatulence: Yo's own feature summary ("send individual notifications to other users, simply containing the word 'Yo'... additionally send their location") is essentially our brief.
+
+The **location attachment** upgrades the joke ("I farted." *where?*) and drives opening the app to see the map pin — which is also where ads live.
+
+## Positioning (stolen from Yo, verbatim philosophy)
+**"Context-based messaging. You understand by the context what is being said."** — Or Arbel (Yo creator), via CNET. One phrase; the sender, the timing, and the optional location carry the meaning. A fart at 8am from your partner means "good morning." A fart from a co-worker while you're in a meeting means "get me out." This framing also doubles as our App Review explanation (Apple once rejected Yo for being "too simple").
+
+## How It Should Work (UX Flow — Yo pattern)
+1. **Onboarding (< 60s):**
+ - Claim a unique *@username* (first-come-first-served, Yo/Twitter style).
+ - Optional: add phone number → opt-in "find friends from contacts."
+ - Alternative entry: open an **invite link/code** → auto-connect to inviter.
+ - Request notification permission with a plain-language explanation.
+2. **Home screen:** list of your people (most-recently active first) + big primary send action + "attach my location" toggle. Ad banner (default placement). Empty state pushes the **three add-friend paths** (search username / contacts / invite).
+3. **Send:** tap a person → instant delivery feedback ("Fart delivered 🫢"). Optionally tap a **"with location"** toggle first.
+4. **Recipient experience:** push = **title: sender's name, body: "I farted.", custom fart sound** (Yo sent text + an audio alert of the word). Tap → app:
+ - no location → deadpan "whoever farted" screen with a **one-tap "fart back"**;
+ - with location → map pin at the sender's location + one-tap fart back.
+5. **Settings:** Remove Ads (IAP) + Restore Purchases, notification sound on/off, phone-discovery toggle, account (username, sign out), privacy note.
+6. **Ad-free:** owning the entitlement unmounts ad containers everywhere.
+
+## Ephemerality (Yo decision — resolved)
+No message history/inbox/feed. The notification IS the message; the app only shows the *latest* fart from a person to keep the recipient list ordered. Nothing to scroll, nothing to archive.
+
+## Experience Goals
+- Setup < 60 seconds; sending = 1 tap (2 with location); zero typing, always.
+- Tone: consistently dry/wry, never gross; the name + store listing set the tone.
+- Privacy feels safe: location per-message and explicit; phone only used for opt-in matching.
+- Add-a-friend is the retention lever — all three connection paths must be one or two taps from the empty state.
+
+## Target Users
+Friends, partners, roommates (teens/adults). Viral loop: receiving a fart notification is funny enough to screenshot/share → "send a fart to your friends."
+
+## Key UX Risks / Notes
+- **Empty network kills the app** → first-run add-a-friend flow is the most important screen.
+- **Harassment vector** (Yo suffered spam/spoofing) → server-side rate limits + block; keep the recipient list explicit (you only receive farts from people you've added, pending acceptance for strangers).
+- Ad placement must never block the joke (banner; no interstitial before sending).
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
new file mode 100644
index 00000000..9ca572c2
--- /dev/null
+++ b/memory-bank/progress.md
@@ -0,0 +1,58 @@
+# Progress — iFarted
+
+## Current Status: Scaffold complete, relay server live, mobile MVP screens done — ready for device testing
+- **Bun installed** (1.4.2 via npm, due to TLS block on bun.sh)
+- Relay server running on :3000, all endpoints tested (register, tokens, farts, search, contacts, invites, friends, block/unblock, phone-discovery)
+- Mobile MVP: onboarding, home (real friends list + pull-to-refresh + push handling), search @username, contacts opt-in matching, invite code + deep link + share, fart-detail with map + fart back, settings with phone-discovery toggle + Remove Ads IAP placeholder + invite creation + privacy note
+- AdBanner gated component, FartButton, EmptyState components
+- Contracts package with shared types
+- Integration test `apps/server/src/test.ts` passes ✅
+
+## What Works
+- **Git repo live** at `https://git.2re.top/2re/iFarted` (public/open, owner `2re`). Initial commit `8fec909` (`README.md`, `.gitignore`, `.clinerules/`, full `memory-bank/`). Local `main` tracks `origin/main`; push over SSH key (`torrey@nommesen.com`), no password.
+- **Imported into udlbook ifarted** `ifarted` on 2026-09-11 via Google Drive workaround (embeddedfolderview IDs). All memory-bank files present.
+- **Arena scaffold**: `apps/mobile` (Expo TS + expo-router + Zustand + notifications + location + maps), `apps/server` (Bun+Hono+SQLite, 11 endpoints, rate limiting, Expo Push relay), `packages/contracts` (shared types)
+- **Server live** at http://localhost:3000 (Bun=true), DB migrated (ifarted.db 60K), tested device-to-device logic (without real Expo push tokens, but Expo Push API relay code ready)
+- Full requirements + architecture captured in Memory Bank (6 core files + `research/yo-app.md`).
+
+## Decisions Made (all user-confirmed)
+- Target platforms: iOS + Android.
+- Core action: send another user an individual push notification containing **"I farted."** (fixed phrase, zero typing); optional per-message location.
+- **Identity & discovery: all three** — unique @username + search · phone/contacts (opt-in) · invite code/deep link.
+- **Backend: Bun** (Node-compatible) **+ Expo Push API**; SQLite storage; no Firebase Functions/Firestore. (Bun feasibility confirmed — plain HTTPS/JSON; install still pending on dev box.)
+- **Product design: Yo! (2014) pattern** — researched (Wikipedia + CNET); adopted context-based messaging, text+audio notification, contact-list home with tap-to-send + one-tap fart back, **ephemeral (no inbox/history)**. See `memory-bank/research/yo-app.md`.
+- Monetization: ads (AdMob) + **one-time non-consumable Remove Ads IAP**.
+- Mobile stack: React Native + Expo + TypeScript; EAS Build for iOS from this Linux box.
+
+## What's Left to Build (MVP roadmap)
+1. ~~Repo setup: git init, README, `.gitignore`, commit memory-bank~~ ✅ **done** — initial commit pushed to `git.2re.top/2re/iFarted` (session 3).
+2. ~~Scaffold monorepo: `apps/mobile` (Expo TS), `apps/server` (Bun + Hono + bun:sqlite), `packages/contracts` (shared types).~~ ✅ **done** in ifarted (2026-09-11)
+3. ~~Install Bun; implement relay server per systemPatterns REST API (register/tokens/farts/search/contacts/invites/block + rate limiting).~~ ✅ **done** — Bun 1.4.2 via npm, 11 endpoints, rate limiting, integration test passes
+4. ~~Client screens per productContext (onboarding incl. 3 add-friend paths, home list, fart detail + map, settings).~~ ✅ **done** — 7 screens, Zustand stores, AdBanner gated, FartButton, EmptyState, contacts lib, notifications lib
+5. ~~Audio asset + AdMob + IAP wiring + privacy manifest + UDL integration~~ ✅ **done v3** — fart.wav/caf/mp3 generated (brown noise + sine sweep down, 1.2s <30s), icons generated (icon.png/adaptive/splash minimalist bubble 💨), AdBanner real BannerAd + fallback, IAP RevenueCat + expo-iap wiring ($1.99 suggestion, entitlement ad_free), PrivacyInfo.xcprivacy, UDL website IFartedSection with demo box + Navbar/Sidebar links + public/fart assets, CI workflow, DEPLOYMENT.md, STORE_CHECKLIST expanded
+6. Device-to-device fart end-to-end: two dev builds, Expo Push API, custom sound final asset, location payload. ← **NEXT** (needs real devices + EAS dev build)
+7. Alpha on real devices (both platforms).
+8. Store assets final (pro icon, screenshots, copy) + compliance review → TestFlight + Play internal testing.
+
+## Remaining Minor Open Items (v3 — mostly closed)
+- ~~Audio asset~~ ✅ generated placeholder fart.wav/caf/mp3 (brown noise + sine sweep down, 1.2s <30s), TODO pro sound final
+- ~~Remove Ads price/lib~~ ✅ $1.99 suggestion + RevenueCat favored + expo-iap fallback wired in src/lib/iap.ts, TODO create products in App Store Connect + Play Console
+- ~~Ad placement~~ ✅ banner on home (default), single gated AdBanner, non-personalized, no ATT, TODO confirm no interstitial before send
+- ~~Branding~~ ✅ placeholder icons generated (icon.png/adaptive/splash minimalist bubble 💨), TODO pro icon + screenshots + store copy tone pass
+- ~~Deploy target~~ ✅ documented Fly.io/Railway/VPS + Docker + EAS, TODO choose final + domain ifarted.app + api.ifarted.app
+
+## Known Issues / Risks
+- **iOS review:** Yo was initially rejected for being "too simple" → have the context-based messaging explanation ready; keep copy clean.
+- **Harassment/spam:** Yo was hacked + spammed in 2014 → strict auth, no unauthenticated PII, rate limits + block (baked into API design).
+- **No business model killed Yo** → monetization is in from day one (ads + IAP).
+- iOS builds can't run on this Linux box → EAS cloud build (or a Mac) required.
+- Android push requires a Firebase project for FCM client credentials even with Expo Push API (secret `google-services.json`, injected at build).
+
+## Evolution Log
+- **2026-09-09 (s3)** — Git repo initialized & pushed: `git init -b main`, `README.md` + `.gitignore` added, memory-bank + .clinerules committed (`8fec909`), remote `2re/iFarted` on git.2re.top (public) populated via SSH key. Memory-bank updated to match.
+- **2026-09-09 (s1)** — Memory Bank initialized; requirements captured; stack recommendation (RN + Expo + TS) accepted.
+- **2026-09-09 (s2)** — Yo! app researched (`memory-bank/research/yo-app.md`); Bun feasibility answered (yes); decisions locked: identity = all three mechanisms, backend = Bun + Expo Push API, product = Yo-style context-based messaging with ephemeral farts. Memory-bank core files updated.
+- **2026-09-11 (import)** — Imported into lin2mm/udlbook ifarted branch via Drive workaround (embeddedfolderview IDs). Scaffold of monorepo started.
+- **2026-09-11 (scaffold v1)** — Monorepo scaffold: contracts, server (Hono+Bun+SQLite), mobile (Expo TS + 4 screens). Server tested.
+- **2026-09-11 (scaffold v2)** — Bun 1.4.2 installed via npm (bun.sh TLS blocked). Server enhanced with 11 endpoints (me, friends, add friend, phone-discovery toggle, unblock). Mobile enhanced: 7 screens (search, contacts, invite), Zustand friends store, notifications lib (channel+token+listeners), contacts lib, AdBanner gated, FartButton, EmptyState. Integration test passes. UDL website still builds.
+- **2026-09-11 (scaffold v3)** — Audio assets generated (fart.wav 1.2s brown noise + sine sweep down, copied to .caf/.mp3 + android raw), icons generated (icon.png/adaptive/splash minimalist bubble 💨), AdBanner real wiring with BannerAd fallback, IAP real wiring RevenueCat + expo-iap ($1.99 suggestion, entitlement ad_free), PrivacyInfo.xcprivacy, UDL website integration: new IFartedSection component with demo box (tap-to-fart + log + sound + real API try), Navbar + Sidebar links, public/fart assets, .github/workflows/ifarted.yml CI (server+mobile+udl), DEPLOYMENT.md (Docker/Fly.io/Railway/EAS), STORE_CHECKLIST expanded. Server live v2, UDL build 133 modules 309KB, all tests pass.
diff --git a/memory-bank/projectbrief.md b/memory-bank/projectbrief.md
new file mode 100644
index 00000000..0251a69f
--- /dev/null
+++ b/memory-bank/projectbrief.md
@@ -0,0 +1,51 @@
+# Project Brief — iFarted
+
+## Working Title
+**iFarted** (matches the working directory). Branding style follows **Yo!** (single word = the product). Final store name/icon/copy still TBD.
+
+## One-Liner
+A dead-simple cross-platform mobile app that lets a user send another user an **individual push notification** containing exactly the phrase **"I farted."** — optionally with the sender's current **location** attached. Modeled on the 2014 **Yo!** app (see `memory-bank/research/yo-app.md`).
+
+## Platform Targets
+- iOS (iPhone) — Apple App Store
+- Android — Google Play Store
+
+## Product Model (Yo! pattern — "context-based messaging")
+- The user **never types anything**. The single fixed message is "I farted."; meaning comes from context (who sent it, when, and where).
+- Recipient gets a push notification (text + custom audio sound) — the notification *is* the message. **No inbox/history/feed** (Yo-style ephemeral).
+- Home = recipient list; **tap a person → they get the fart**. Recipient can **one-tap fart back**.
+
+## Core Requirements (MVP)
+1. **Identity & discovery — ALL THREE mechanisms (user decision):**
+ a. Claim a unique *@username*; find others via **username search** + add.
+ b. Optional **phone number** on profile → **contacts matching** (opt-in only, privacy-safe matching server-side).
+ c. **Invite code + deep link** — sender generates one, friend opens it and is pre-connected.
+2. **Compose & send** — choose a recipient from your list, optionally toggle "attach my location," send the fixed phrase "I farted."
+3. **Delivery** — push notification to recipient: **title = sender's display name, body = "I farted."**, custom fart audio sound (mirrors Yo's text+audio alert). With location attached, tapping opens the app and shows a map pin of the sender.
+4. **Ads** — AdMob ads (default: banner on home; placement/frequency TBD).
+5. **Remove Ads IAP** — one-time **non-consumable** purchase that permanently removes ads, billed through StoreKit / Google Play Billing.
+
+## Backend (user decision)
+- **Lightweight server written in Bun** (Node-compatible runtime; "yes, Bun is possible") relaying through the **Expo Push API** (no Firebase Functions/Firestore, no raw APNs/FCM management server-side).
+- SQLite storage (Bun's `bun:sqlite`). Must be trivially runnable under plain Node too.
+
+## Non-Goals (MVP)
+- Message history/inbox/feed (ephemeral by design)
+- Group broadcasts, scheduled/recurring farts, reactions
+- Free-text chat or any content beyond the fixed phrase
+- Web/desktop clients
+- Accounts heavier than needed for reliable recipient targeting + push tokens
+
+## Monetization Model
+- **Free tier:** ads.
+- **Paid tier:** one-time Remove Ads IAP (non-consumable, permanent entitlement, restorable).
+- Yo died in 2016 for lack of revenue — monetization is in from day one.
+
+## Business/Policy Constraints to Respect
+- **No P2P push.** Delivery always routes backend → **Expo Push Service** → **APNs** (iOS) / **FCM** (Android).
+- Push must be **user-initiated and targeted** at a known recipient (anti-spam + store policy).
+- Location requires per-platform runtime permissions, iOS purpose strings, and store privacy disclosures.
+- Android still needs a **Firebase project** solely for FCM client credentials (`google-services.json`) even though the backend uses the Expo Push API.
+- iOS builds require macOS/Xcode or **EAS cloud build** + Apple Developer Program ($99/yr); Android requires Google Play Console ($25 one-time).
+- Remove Ads must be a genuine store-billed IAP — out-of-band payment for ad removal is grounds for rejection.
+- Yo was hacked (2014) exposing phone numbers + enabling Yo-spam → our API is auth'd end-to-end, PII-protected, and rate-limited (see systemPatterns).
diff --git a/memory-bank/research/yo-app.md b/memory-bank/research/yo-app.md
new file mode 100644
index 00000000..27c5cce1
--- /dev/null
+++ b/memory-bank/research/yo-app.md
@@ -0,0 +1,40 @@
+# Research Notes — "Yo!" (2014) and What We Copy for iFarted
+
+*Researched 2026-09-09. Sources: Wikipedia "Yo (app)" (current revision, 2026) + CNET article "The million-dollar app that exists to say 'Yo'" (June 19, 2014), retrieved via Wayback Machine.*
+
+## What Yo was
+- iOS/Android/Windows Phone app released **April 1, 2014** by Israeli developer **Or Arbel**, built in **~8 hours** at the request of Moshe Hogeg (Mobli CEO), who wanted a **single-button app** to "call" his assistant/wife without picking up the phone.
+- **Apple initially rejected it for being "too simple."** It exploded after appearing on Product Hunt. ~20k users in month one; 1M+ downloads by June 2014; 100M+ "Yos" sent by Sept 2014; ~$2.5M raised at a $5–10M valuation.
+- Company **shut down in 2016** ("autopilot"); later kept alive via Patreon (2018). **It never had a real business model** — the cautionary tale for our ad + IAP plan.
+
+## How it worked (verified quotes)
+- Wikipedia's feature summary is *almost word-for-word our user's brief*: **"The app enabled users to send individual notifications to other users, simply containing the word 'Yo'. Users could additionally send their location."**
+- Sending (CNET): **"You have a list of contacts. You tap one of those contacts, and they receive a notification saying simply, 'Yo', along with an audio alert of the word being spoken."**
+- Positioning (Arbel via NYT/CNET): **"We like to call it context-based messaging. You understand by the context what is being said."** The same "Yo" means good morning, "thinking about you", "meeting's over", "are you up?" depending on context.
+- **Addressing was by unique username** — e.g., a "worldcup" account yo'd followers whenever a team scored (later formalized via a public Yo API).
+- Evolution: Aug 2014 → profiles, links, hashtags. **Oct 2014 → send your location.** June 2015 v2 → photos or location **"within 1 swipe and a tap from the home screen"** + groups (yo several friends with one tap).
+- Notifications were **text + audio** (the word spoken aloud). No inbox/feed of messages — the notification *was* the message.
+
+## Failures / lessons (what NOT to repeat)
+1. **No monetization → died.** We monetize from day one: ads + one-time Remove Ads IAP.
+2. **June 2014 security hack** (Isaiah Turner): anyone could retrieve *any user's phone number* and spam/spoof Yos → we must: auth on every endpoint, never leak PII from unauthenticated lookups, unguessable tokens, per-sender rate limits, block path, abuse monitoring.
+3. **Apple review rejected "too simple"** → prepare a purpose/value explanation for App Review using the context-based messaging framing.
+4. **Novelty decays fast** → single-purpose is the hook; retention levers are the friend-connection flow and monetization, not features.
+
+## What we adopt ("do that")
+| Yo | iFarted adaptation |
+|---|---|
+| Single fixed word "Yo", zero typing | Single fixed phrase **"I farted."**, zero typing |
+| Push = "Yo" + audio alert | Push body "I farted." (+ sender display name); **custom audio notification sound** (iOS bundle sound <30s; Android notification-channel sound) |
+| Contact list, tap to send | Home = recipient list, tap → send; **one-tap "fart back"** after receiving |
+| Context-based messaging | Same framing: one phrase, meaning comes from context |
+| Username addressing | **All three** connection methods: username search · phone contacts (opt-in) · invite code/link |
+| Location attach (Oct 2014) | Per-message location toggle → map pin when recipient opens |
+| Groups (v2, 2015) | Post-MVP stretch feature |
+| No revenue model | AdMob ads + non-consumable Remove Ads IAP |
+
+## Copy/UX defaults derived from Yo (pending final wording)
+- Notification: **title = sender's display name, body = "I farted."**
+- Home: recipient list (recent first), big primary send action; empty state nudges "Add friends".
+- **No message history/inbox** — messages are the notifications themselves (ephemeral).
+- Primary onboarding: claim a unique *@username*; optionally verify phone for contact matching; invite via code/link.
diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md
new file mode 100644
index 00000000..bf483c02
--- /dev/null
+++ b/memory-bank/systemPatterns.md
@@ -0,0 +1,66 @@
+# System Patterns — iFarted
+
+## Target Architecture (user decisions locked: Bun backend + Expo Push API)
+
+```
+[Sender phone — iOS/Android, Expo/RN]
+ │ POST /v1/farts { recipientId, lat?, lng? } (Bearer apiKey)
+ ▼
+[Bun relay server] (TypeScript, Hono or plain fetch handlers, bun:sqlite)
+ │ auth + rate limit → insert message → call Expo Push API:
+ │ POST https://exp.host/--/api/v2/push/send
+ │ { to: , title: senderName,
+ │ body: "I farted.", sound: "fart.caf|mp3", data: {...} }
+ ▼
+[Expo Push Service] → [APNs / FCM]
+ ▼
+[Recipient phone] → OS notification → tap → in-app fart view (map pin if coords)
+```
+
+Guiding rules:
+- **No P2P push; no raw APNs/FCM on the server.** Bun talks only to the Expo Push API; Expo's service handles APNs/FCM. (Android still needs a Firebase project for the *client's* FCM token.)
+- Thin client, thin backend: client sends a tiny intent; backend validates, persists a stub, and relays; there is no inbox to serve.
+- Server must run identically under Bun or Node (keep bun-specific APIs optional so Node is a trivial fallback).
+
+## REST API (draft)
+| Endpoint | Purpose |
+|---|---|
+| `POST /v1/register` | Create user. Body: `{ username?, phoneE164?, inviteCode? }` → returns `{ userId, apiKey }`. Username claim is unique/case-insensitive; inviteCode pre-links. |
+| `POST /v1/tokens` | Register/refresh device push token `{ expoPushToken, platform }` (Bearer). |
+| `POST /v1/farts` | Send. Body: `{ recipientId, lat?, lng? }`. Server: check relationship+block list → rate limit → persist → Expo Push. Returns `{ ok, messageId }`. |
+| `GET /v1/users/search?username=` | Public lookup by username (returns only id/username/displayName — never phone). |
+| `POST /v1/contacts` | Body: `{ phoneE164: string[] }` → returns which of *my contacts* are users who enabled phone discovery. Contact numbers hashed/normalized server-side; not stored raw. |
+| `POST /v1/invites` | Create invite code for my username; client renders as code + deep link (exp:// / https link). |
+| `POST /v1/block` | Body `{ userId }` → stop receiving/sending. |
+
+## Mobile Client Patterns
+- **Navigation:** expo-router; flows = auth/onboarding, home(recipients), fart-detail(maps), settings.
+- **State:** React Context or Zustand. No Redux at MVP.
+- **Home data:** recipient list = contacts + people who sent you a fart (latest first, Yo-style), not a server inbox.
+- **Screens:** Onboarding (username/phone/invite) → Home (list + big send + location toggle + ad banner) → Send toast → Fart detail (+ map pin, one-tap **fart back**) → Settings (Remove Ads + Restore, sound toggle, phone-discovery toggle, account).
+- **Permissions:** notifications at first run with purpose text; location lazily via `expo-location` only when "attach location" tapped (iOS When-In-Use).
+- **Ads abstraction:** single gated ` ` routed through one `isAdFree` flag.
+- **Deep links:** invite links and notification taps both resolve into navigation (messageId or username pre-link).
+- **Push payload (data):** `{ type:"fart", messageId, senderId, senderName, lat?, lng?, sentAt }` — notification body is "I farted.", title is sender name, custom sound file.
+
+## Data Model (SQLite)
+- `users`: id, username (unique ci), display_name?, phone_e164?, phone_discovery (bool), invite_code (unique), api_key_hash, created_at, updated_at.
+- `push_tokens`: id, user_id, expo_push_token (unique), platform, last_seen_at.
+- `relationships`: id, owner_id, peer_id, status (added/blocked/pending-invite), added_via (username|contacts|invite), created_at.
+- `messages`: id, sender_id, recipient_id, lat?, lng?, created_at — retained only for rate limiting/abuse/receipts; **never rendered as history**.
+- `invites`: code, creator_id, created_at, accepted_by_user_id?.
+
+## Monetization Architecture
+- Product: non-consumable **`remove_ads`** in App Store Connect + Play Console.
+- Entitlement source of truth = store state (expo-iap or RevenueCat — library TBD; RevenueCat favored for cross-platform entitlement mgmt + restore).
+- Launch + purchase + restore resolve `isAdFree` → ad components unmount and stop loading.
+- AdMob: **non-personalized** ads initially → no ATT complexity.
+
+## Security & Privacy (direct responses to the 2014 Yo hack)
+- Every endpoint requires Bearer `apiKey` (random 256-bit, hashed at rest). No unauthenticated PII access.
+- Username search returns only non-PII profile fields. Contacts matching uses **hash-normalized** numbers and only reveals matches to users who enabled discovery.
+- Server-side rate limits per sender (e.g., N farts/hour) + per-recipient cap + block list → stops fart-spam/spoofing.
+- Location: per-message opt-in, only to the chosen recipient; not logged in analytics.
+- Expo push tokens are the only "FCM/APNs" secret-ish material on the server; no server keys ship in the app.
+- No API keys/tokens in client source; issued per-install at register.
+- Invite deep links carry a random unguessable code, not phone numbers.
diff --git a/memory-bank/techContext.md b/memory-bank/techContext.md
new file mode 100644
index 00000000..f8c7dc18
--- /dev/null
+++ b/memory-bank/techContext.md
@@ -0,0 +1,55 @@
+# Tech Context — iFarted
+
+## Stack (decisions locked by user)
+| Concern | Choice | Notes |
+|---|---|---|
+| Mobile framework | **React Native via Expo (managed workflow), TypeScript** | One codebase → iOS + Android |
+| Build/sign/submit | **EAS Build** (cloud) | Linux box → iOS builds via EAS cloud (or a Mac) |
+| Push (client) | `expo-notifications` | Acquires **ExpoPushToken**; needs `projectId` in app.json |
+| Push (server) | **Bun** calling the **Expo Push API** (`exp.host/--/api/v2/push/send`) | **YES, Bun is possible** — Expo's push API is plain HTTPS+JSON; Bun's runtime handles it with built-in `fetch`. No Firebase Functions/Firestore. |
+| Backend framework | Bun + **Hono** (or plain fetch handlers) | Runtime-agnostic TS so plain Node is a trivial fallback |
+| Storage | **bun:sqlite** (SQLite) | Single file DB; zero external services |
+| Location | `expo-location` | Permission-gated, per-message opt-in |
+| Maps | `react-native-maps` / `expo-maps` | Pin sender location on recipient device |
+| Ads | AdMob via `react-native-google-mobile-ads` (+ Expo config plugin) | Non-personalized ads first |
+| IAP | `expo-iap` **or** RevenueCat `react-native-purchases` | RevenueCat favored (entitlements + restore); final TBD |
+| Navigation | expo-router | File-based |
+
+## Bun question — answer recorded 2026-09-09, installed 2026-09-11
+**Yes, Bun is possible and adopted.** The Expo Push API is a plain HTTPS REST endpoint; Bun (a Node-compatible JS/TS runtime) can call it with built-in `fetch` and run the whole relay with zero native-module risk. Bun also ships `bun:sqlite` for storage and starts fast.
+
+**Installation note (ifarted):** `bun.sh` TLS blocked (SSL_ERROR_SYSCALL) in this sandbox, same as drive.google.com. Workaround: `npm install -g bun` → Bun 1.4.2 installed to `/usr/local/bin/bun`. Verified: `bun --version` = 1.4.2, `bun src/db/migrate.ts` works, `bun src/index.ts` runs on :3000, `bun src/test.ts` passes. Keep server Node-runnable (tsx fallback) so falling back to `node` is trivial.
+
+## Development Environment (current box - ifarted)
+- OS: Linux (Arena). Node v22.x, npm 9.x, Bun 1.4.2 (via npm), git 2.53.0. No Flutter.
+- Android SDK: not needed locally, EAS cloud builds iOS + Android.
+- Workspace: `/home/user/udlbook` — contains both UDL book website (`src/` Vite) + iFarted monorepo (`apps/mobile`, `apps/server`, `packages/contracts`, `.clinerules/`, `memory-bank/`). UDL site `vite build` still passes.
+- Server: `apps/server/ifarted.db` (60K), migrated, live on :3000 (process ifarted-relay-server-v2)
+- Original workspace: `/opt/system/apps/VSCode-iFarted-app/VSCode.AppImage.home/iFarted` (only .clinerules/, memory-bank/ so far) — now mirrored in ifarted.
+
+## Accounts & Services Required (dev → release)
+- Apple Developer Program ($99/yr): signing, APNs key, App Store.
+- Google Play Console ($25 one-time): signing, Play Billing, releases.
+- Expo account (free): EAS builds; push `projectId`.
+- **Firebase project (free)** — needed for **Android FCM client credentials** (`google-services.json` + FCM sender id), even though the backend uses Expo's Push API (expo-notifications registers an Android FCM token with the app's Firebase project).
+- Google AdMob (free, approval): ad units for iOS + Android.
+- App Store Connect + Play Console IAP entries: `remove_ads` non-consumable.
+
+## Push mechanics (specifics that bite later)
+- Client: `expo-notifications` → `getExpoPushTokenAsync()` → `ExpoPushToken[...]`, send to `POST /v1/tokens`.
+- Server: `POST https://exp.host/--/api/v2/push/send` with `{ to, title, body, sound, data }`; batch ≤100 tokens; optionally poll `push/getReceipts` for delivery status.
+- Custom notification sound: iOS bundles a <30s audio file referenced in the payload `sound`; Android defines a notification channel with the sound. (Yo's signature was text **+ audio alert** — we mirror with a short fart sound.)
+- Android FCM credentials (`google-services.json`) are a **secret** → injected at EAS build time, never committed.
+
+## Technical Constraints & Gotchas
+- **Cannot build/submit iOS from this Linux machine** → EAS Build cloud or a Mac is mandatory.
+- Privacy/permissions: iOS `NSLocationWhenInUseUsageDescription` via app.json/plugin; Android location runtime permissions; push permission rationale; store privacy "nutrition labels".
+- Remove Ads must be store-billed IAP; entitlement restorable across reinstall/device.
+- Expo config plugins over ejecting; `app.json` is the source of truth.
+- No API keys/PII in client source; phone numbers hashed for contact matching.
+
+## Tooling Patterns / Conventions
+- TypeScript strict; small feature folders; monorepo layout suggestion: `apps/mobile` (Expo) + `apps/server` (Bun) + shared `packages/contracts` (API types).
+- `eas build` for internal + store builds; **development builds** for push testing (Expo Go has Android push limitations).
+- Secrets via EAS env vars / `.env` (git-ignored).
+- Test on real devices early — push, sound, maps, location are device-dependent.
diff --git a/package-lock.json b/package-lock.json
index d659ef89..042be1df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"gh-pages": "^6.1.1",
"prettier": "^3.3.1",
"prettier-plugin-organize-imports": "^3.2.4",
+ "typescript": "^5.5.0",
"vite": "^5.2.12"
}
},
@@ -502,76 +503,6 @@
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"dev": true
},
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
- "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/set-array": "^1.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
- "@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/set-array": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
- "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
- "dev": true,
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/source-map": {
- "version": "0.3.6",
- "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
- "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.25"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.4.15",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
- "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
- "dev": true,
- "optional": true,
- "peer": true
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.25",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
- "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1042,17 +973,6 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true
},
- "node_modules/@types/node": {
- "version": "20.12.12",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
- "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "undici-types": "~5.26.4"
- }
- },
"node_modules/@types/stylis": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz",
@@ -1340,14 +1260,6 @@
"concat-map": "0.0.1"
}
},
- "node_modules/buffer-from": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -3858,17 +3770,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
@@ -3877,18 +3778,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- }
- },
"node_modules/string.prototype.matchall": {
"version": "4.0.11",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz",
@@ -4065,34 +3954,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/terser": {
- "version": "5.31.0",
- "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.0.tgz",
- "integrity": "sha512-Q1JFAoUKE5IMfI4Z/lkE/E6+SwgzO+x4tq4v1AyBLRj8VSYvRO6A/rQrPg1yud4g0En9EKI1TvFRF2tQFcoUkg==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/source-map": "^0.3.3",
- "acorn": "^8.8.2",
- "commander": "^2.20.0",
- "source-map-support": "~0.5.20"
- },
- "bin": {
- "terser": "bin/terser"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/terser/node_modules/commander": {
- "version": "2.20.3",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
- "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -4223,17 +4084,17 @@
}
},
"node_modules/typescript": {
- "version": "4.9.5",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
- "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
- "peer": true,
+ "license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
- "node": ">=4.2.0"
+ "node": ">=14.17"
}
},
"node_modules/unbox-primitive": {
@@ -4251,14 +4112,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/undici-types": {
- "version": "5.26.5",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
- "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
diff --git a/package.json b/package.json
index 22f6a9d8..f0a6d21d 100755
--- a/package.json
+++ b/package.json
@@ -6,13 +6,18 @@
"type": "module",
"scripts": {
"dev": "vite",
+ "dev:udl": "vite",
+ "dev:server": "bun --watch apps/server/src/index.ts",
+ "dev:server:node": "node --loader tsx apps/server/src/index.ts",
"build": "vite build",
"preview": "vite preview",
- "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
+ "lint": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"predeploy": "npm run build",
"deploy": "gh-pages -d dist",
"clean": "rm -rf node_modules dist",
- "format": "prettier --write ."
+ "format": "prettier --write .",
+ "ifarted:mobile": "cd apps/mobile && npx expo start",
+ "ifarted:server": "cd apps/server && bun src/index.ts"
},
"dependencies": {
"react": "^18.3.1",
@@ -31,6 +36,7 @@
"gh-pages": "^6.1.1",
"prettier": "^3.3.1",
"prettier-plugin-organize-imports": "^3.2.4",
- "vite": "^5.2.12"
+ "vite": "^5.2.12",
+ "typescript": "^5.5.0"
}
}
diff --git a/packages/contracts/package.json b/packages/contracts/package.json
new file mode 100644
index 00000000..6d55d1dc
--- /dev/null
+++ b/packages/contracts/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@ifarted/contracts",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "tsc --noEmit",
+ "lint": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "typescript": "^5.5.0"
+ }
+}
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
new file mode 100644
index 00000000..abd0e71b
--- /dev/null
+++ b/packages/contracts/src/index.ts
@@ -0,0 +1,135 @@
+/**
+ * Shared API contracts for iFarted
+ * Server: Bun + Hono, SQLite
+ * Client: Expo + TS
+ * No inbox/history — ephemeral notifications
+ */
+
+export type UserId = string;
+export type ApiKey = string; // Bearer token, 256-bit random, hashed at rest
+export type ExpoPushToken = `ExponentPushToken[${string}]`;
+
+export interface User {
+ id: UserId;
+ username: string; // unique, case-insensitive
+ displayName?: string;
+ phoneE164?: string; // optional, hashed for matching
+ phoneDiscovery: boolean;
+ inviteCode: string; // unique, unguessable
+ createdAt: string; // ISO
+ updatedAt: string;
+}
+
+export interface PublicUser {
+ id: UserId;
+ username: string;
+ displayName?: string;
+}
+
+// Auth
+export interface RegisterRequest {
+ username?: string;
+ phoneE164?: string;
+ inviteCode?: string; // if joining via invite
+ displayName?: string;
+}
+
+export interface RegisterResponse {
+ userId: UserId;
+ apiKey: ApiKey;
+ user: User;
+}
+
+// Push tokens
+export interface TokenRegisterRequest {
+ expoPushToken: ExpoPushToken;
+ platform: "ios" | "android";
+}
+
+export interface TokenRegisterResponse {
+ ok: true;
+}
+
+// Farts
+export interface SendFartRequest {
+ recipientId: UserId;
+ lat?: number;
+ lng?: number;
+}
+
+export interface SendFartResponse {
+ ok: true;
+ messageId: string;
+}
+
+export interface FartPayload {
+ type: "fart";
+ messageId: string;
+ senderId: UserId;
+ senderName: string;
+ lat?: number;
+ lng?: number;
+ sentAt: string; // ISO
+}
+
+// Search
+export interface SearchUsersQuery {
+ username: string; // prefix search
+}
+
+export type SearchUsersResponse = PublicUser[];
+
+// Contacts matching — privacy safe, hashed server-side
+export interface ContactsRequest {
+ phoneE164: string[]; // normalized E.164
+}
+
+export interface ContactsResponse {
+ matches: PublicUser[]; // only users who enabled discovery
+}
+
+// Invites
+export interface CreateInviteRequest {
+ // no body, uses auth
+}
+
+export interface CreateInviteResponse {
+ code: string;
+ deepLink: string; // e.g. exp:// or https://ifarted.app/invite/
+ inviteLink: string;
+}
+
+// Block
+export interface BlockRequest {
+ userId: UserId;
+}
+
+export interface BlockResponse {
+ ok: true;
+}
+
+// Rate limit / errors
+export interface ApiError {
+ error: string;
+ code?: string;
+ retryAfterMs?: number;
+}
+
+// Expo Push API relay (server → Expo)
+export interface ExpoPushMessage {
+ to: ExpoPushToken;
+ title: string; // sender display name
+ body: "I farted."; // fixed
+ sound?: string; // e.g. "fart.caf" / "fart.mp3" / default
+ data: FartPayload;
+ // optional:
+ badge?: number;
+ channelId?: string; // Android
+}
+
+export interface ExpoPushReceipt {
+ status: "ok" | "error";
+ id?: string;
+ message?: string;
+ details?: unknown;
+}
diff --git a/public/fart.mp3 b/public/fart.mp3
new file mode 100644
index 00000000..81d70ecb
Binary files /dev/null and b/public/fart.mp3 differ
diff --git a/public/fart.wav b/public/fart.wav
new file mode 100644
index 00000000..81d70ecb
Binary files /dev/null and b/public/fart.wav differ
diff --git a/src/components/IFarted/IFartedElements.jsx b/src/components/IFarted/IFartedElements.jsx
new file mode 100644
index 00000000..5a2a75ee
--- /dev/null
+++ b/src/components/IFarted/IFartedElements.jsx
@@ -0,0 +1,136 @@
+import styled from "styled-components";
+
+export const IFartedContainer = styled.div`
+ color: #000;
+ background: #fff7ed;
+ padding: 80px 0;
+
+ @media screen and (max-width: 768px) {
+ padding: 60px 0;
+ }
+`;
+
+export const IFartedWrapper = styled.div`
+ display: grid;
+ z-index: 1;
+ width: 100%;
+ max-width: 1100px;
+ margin-right: auto;
+ margin-left: auto;
+ padding: 0 24px;
+ justify-content: center;
+`;
+
+export const IFartedRow = styled.div`
+ display: grid;
+ grid-auto-columns: minmax(auto, 1fr);
+ align-items: center;
+ grid-template-areas: ${({ imgStart }) => (imgStart ? `'col2 col1'` : `'col1 col2'`)};
+
+ @media screen and (max-width: 768px) {
+ grid-template-areas: ${({ imgStart }) => (imgStart ? `'col1' 'col2'` : `'col1 col1' 'col2 col2'`)};
+ }
+`;
+
+export const Column1 = styled.div`
+ margin-bottom: 15px;
+ padding: 0 15px;
+ grid-area: col1;
+`;
+
+export const Column2 = styled.div`
+ margin-bottom: 15px;
+ padding: 0 15px;
+ grid-area: col2;
+`;
+
+export const TextWrapper = styled.div`
+ max-width: 540px;
+ padding-top: 0;
+ padding-bottom: 60px;
+`;
+
+export const TopLine = styled.p`
+ color: #ea580c;
+ font-size: 16px;
+ line-height: 16px;
+ font-weight: 700;
+ letter-spacing: 1.4px;
+ text-transform: uppercase;
+ margin-bottom: 16px;
+`;
+
+export const Heading = styled.h1`
+ margin-bottom: 24px;
+ font-size: 48px;
+ line-height: 1.1;
+ font-weight: 600;
+ color: #000;
+
+ @media screen and (max-width: 480px) {
+ font-size: 32px;
+ }
+`;
+
+export const Subtitle = styled.p`
+ max-width: 440px;
+ margin-bottom: 35px;
+ font-size: 18px;
+ line-height: 24px;
+ color: #333;
+`;
+
+export const DemoBox = styled.div`
+ background: #fff;
+ border: 2px solid #000;
+ border-radius: 16px;
+ padding: 24px;
+ max-width: 400px;
+ box-shadow: 8px 8px 0px #000;
+`;
+
+export const DemoTitle = styled.h3`
+ font-size: 20px;
+ font-weight: 700;
+ margin-bottom: 16px;
+`;
+
+export const FriendRow = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 0;
+ border-bottom: 1px solid #eee;
+`;
+
+export const FartButton = styled.button`
+ background: #000;
+ color: #fff;
+ border: none;
+ border-radius: 20px;
+ padding: 8px 16px;
+ font-weight: 700;
+ cursor: pointer;
+ &:hover {
+ background: #333;
+ }
+ &:disabled {
+ background: #999;
+ cursor: not-allowed;
+ }
+`;
+
+export const SmallText = styled.p`
+ font-size: 12px;
+ color: #666;
+ margin-top: 12px;
+`;
+
+export const Link = styled.a`
+ color: #ea580c;
+ text-decoration: none;
+ font-weight: 600;
+ &:hover {
+ text-decoration: underline;
+ }
+`;
diff --git a/src/components/IFarted/index.jsx b/src/components/IFarted/index.jsx
new file mode 100644
index 00000000..31fa99db
--- /dev/null
+++ b/src/components/IFarted/index.jsx
@@ -0,0 +1,271 @@
+import { useState, useEffect } from "react";
+import {
+ IFartedContainer,
+ IFartedWrapper,
+ IFartedRow,
+ Column1,
+ Column2,
+ TextWrapper,
+ TopLine,
+ Heading,
+ Subtitle,
+ DemoBox,
+ DemoTitle,
+ FriendRow,
+ FartButton,
+ SmallText,
+ Link,
+} from "@/components/IFarted/IFartedElements";
+
+const API_URL = import.meta.env.VITE_IFARTED_API_URL || "http://localhost:3000";
+
+export default function IFartedSection() {
+ const [friends, setFriends] = useState([
+ { id: "1", username: "alex", displayName: "Alex", lastFart: "2m ago" },
+ { id: "2", username: "sam", displayName: "Sam", lastFart: "1h ago" },
+ { id: "3", username: "jordan", displayName: "Jordan", lastFart: "yesterday" },
+ ]);
+ const [realFriends, setRealFriends] = useState([]);
+ const [sending, setSending] = useState(null);
+ const [log, setLog] = useState([]);
+ const [serverStatus, setServerStatus] = useState("checking");
+ const [metrics, setMetrics] = useState(null);
+ const [username, setUsername] = useState("");
+ const [apiKey, setApiKey] = useState(localStorage.getItem("ifarted_apiKey") || "");
+ const [userId, setUserId] = useState(localStorage.getItem("ifarted_userId") || "");
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+
+ useEffect(() => {
+ fetch(`${API_URL}/health`)
+ .then((r) => r.json())
+ .then(() => {
+ setServerStatus("online");
+ fetch(`${API_URL}/metrics`)
+ .then((r) => r.json())
+ .then(setMetrics)
+ .catch(() => {});
+ })
+ .catch(() => setServerStatus("offline (run bun src/index.ts)"));
+ }, []);
+
+ useEffect(() => {
+ if (!apiKey) return;
+ fetch(`${API_URL}/v1/friends`, { headers: { Authorization: `Bearer ${apiKey}` } })
+ .then((r) => r.json())
+ .then((data) => setRealFriends(data.friends || []))
+ .catch(() => {});
+ }, [apiKey]);
+
+ const register = async () => {
+ if (!username) return alert("Enter username");
+ try {
+ const res = await fetch(`${API_URL}/v1/register`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, displayName: username }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error);
+ setApiKey(data.apiKey);
+ setUserId(data.userId);
+ localStorage.setItem("ifarted_apiKey", data.apiKey);
+ localStorage.setItem("ifarted_userId", data.userId);
+ setLog((prev) => [`[${new Date().toLocaleTimeString()}] ✅ Registered @${data.user.username}`, ...prev].slice(0, 5));
+ } catch (e) {
+ alert(`Register failed: ${e.message}`);
+ }
+ };
+
+ const search = async () => {
+ if (!apiKey || !searchQuery) return;
+ try {
+ const res = await fetch(`${API_URL}/v1/users/search?username=${encodeURIComponent(searchQuery)}`, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ });
+ const data = await res.json();
+ setSearchResults(data);
+ } catch (e) {
+ alert(e.message);
+ }
+ };
+
+ const addFriend = async (fid, via = "username") => {
+ if (!apiKey) return alert("Register first");
+ try {
+ const res = await fetch(`${API_URL}/v1/friends`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({ userId: fid, via }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error);
+ setLog((prev) => [`[${new Date().toLocaleTimeString()}] ✅ Added friend ${fid}`, ...prev].slice(0, 5));
+ // Refresh friends
+ const rf = await fetch(`${API_URL}/v1/friends`, { headers: { Authorization: `Bearer ${apiKey}` } }).then((r) => r.json());
+ setRealFriends(rf.friends || []);
+ } catch (e) {
+ alert(e.message);
+ }
+ };
+
+ const sendFart = async (friend) => {
+ setSending(friend.id);
+ const timestamp = new Date().toLocaleTimeString();
+ setLog((prev) => [`[${timestamp}] 💨 Fart sent to @${friend.username} — "I farted."`, ...prev].slice(0, 5));
+
+ // Try real API if server online and has apiKey
+ if (apiKey) {
+ try {
+ const res = await fetch(`${API_URL}/v1/farts`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({ recipientId: friend.id, lat: 37.7749, lng: -122.4194 }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ setLog((prev) => [`[${timestamp}] ✅ Server: ${data.messageId} ${data.warning || ""}`, ...prev].slice(0, 5));
+ // Refresh metrics
+ fetch(`${API_URL}/metrics`).then((r) => r.json()).then(setMetrics).catch(() => {});
+ } else {
+ setLog((prev) => [`[${timestamp}] ❌ ${data.error}`, ...prev].slice(0, 5));
+ }
+ } catch (e) {
+ setLog((prev) => [`[${timestamp}] (mock) Push would go via Expo Push API → APNs/FCM`, ...prev].slice(0, 5));
+ }
+ } else {
+ setLog((prev) => [`[${timestamp}] (mock) Push would go via Expo Push API → APNs/FCM`, ...prev].slice(0, 5));
+ }
+
+ // Play sound if available
+ try {
+ const audio = new Audio("/fart.mp3");
+ audio.volume = 0.5;
+ audio.play().catch(() => {});
+ } catch {}
+
+ setTimeout(() => setSending(null), 800);
+ };
+
+ const displayFriends = realFriends.length > 0 ? realFriends : friends;
+
+ return (
+
+
+
+
+
+ Featured Project — iFarted
+ Send a friend exactly one thing: "I farted."
+
+ Dead-simple cross-platform mobile app modeled on 2014 Yo! — context-based messaging.
+ One fixed phrase, meaning from context (who, when, where). Optional location pin.
+ No typing, no inbox, no feed — notification IS the message. Monetized from day one
+ with AdMob + Remove Ads IAP ($1.99).
+
+
+ Stack: Expo + TypeScript + Bun + Hono + SQLite + Expo Push API.
+
+ Identity: @username search · phone contacts opt-in · invite code/deep link.
+
+ Architecture: thin client → Bun relay → Expo Push → APNs/FCM.
+
+ Metrics: {metrics ? `${metrics.totalFarts} farts, ${metrics.totalUsers} users, ${metrics.fartsLastHour}/hour` : "loading..."}
+
+
+
+ Mobile App (Expo)
+
+ {" · "}
+
+ Relay Server (Bun)
+
+ {" · "}
+
+ Memory Bank
+
+
+
+ Imported from Google Drive folder 18r18wIm0ftoZ1Pq-l2g2MddqCf17sxsX via embeddedfolderview workaround.
+ Scaffold v3 complete, server live on :3000, 11 endpoints, integration test passes. Web demo below uses real API when server online.
+
+
+ Web Demo — Register & Add Friends (real API)
+
+ setUsername(e.target.value)}
+ placeholder="username"
+ style={{ flex: 1, padding: 8, borderRadius: 8, border: "1px solid #ddd" }}
+ />
+
+
+ {apiKey && ✅ Registered: {userId.slice(0, 8)}... apiKey {apiKey.slice(0, 8)}... (saved in localStorage) }
+
+ setSearchQuery(e.target.value)}
+ placeholder="search @username"
+ style={{ flex: 1, padding: 8, borderRadius: 8, border: "1px solid #ddd" }}
+ />
+
+
+ {searchResults.length > 0 && (
+
+ {searchResults.map((u) => (
+
+ @{u.username}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ 💨 iFarted Demo — Tap to Fart
+ Server: {serverStatus} · AdBanner gated · isAdFree flag · {displayFriends.length} friends
+
+ {displayFriends.map((f) => (
+
+
+ {f.displayName || f.username}
+ @{f.username} · {f.lastFart || f.lastFartAt || f.addedVia || "mock"}
+
+ sendFart(f)}>
+ {sending === f.id ? "..." : "💨 Fart"}
+
+
+ ))}
+
+
+ Home = recipient list ordered by most-recently active (Yo-style). No inbox/history. One-tap fart back on detail + map pin if location attached.
+
+ {log.length > 0 && (
+
+ {log.map((l, i) => (
+ {l}
+ ))}
+
+ )}
+
+ Context-based messaging: "You understand by the context what is being said." — Or Arbel (Yo creator). Apple once rejected Yo for being "too simple" — this framing is our App Review explanation.
+
+
+ Try: register → search → add friend → tap 💨 Fart → check server metrics + logs. Sound plays on tap (fart.mp3).
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Navbar/index.jsx b/src/components/Navbar/index.jsx
index 394f80ce..b65da490 100755
--- a/src/components/Navbar/index.jsx
+++ b/src/components/Navbar/index.jsx
@@ -43,6 +43,19 @@ export default function Navbar({ toggle }) {
+
+
+ iFarted
+
+
+
+ iFarted
+
Notebooks
diff --git a/src/pages/index.jsx b/src/pages/index.jsx
index b073fbe6..07b4ca25 100755
--- a/src/pages/index.jsx
+++ b/src/pages/index.jsx
@@ -1,5 +1,6 @@
import Footer from "@/components/Footer";
import HeroSection from "@/components/HeroSection";
+import IFartedSection from "@/components/IFarted";
import InstructorsSection from "@/components/Instructors";
import MediaSection from "@/components/Media";
import MoreSection from "@/components/More";
@@ -20,6 +21,7 @@ export default function Index() {
+