From 492dfffc2e5aba912be1d854dd1f55940060a3aa Mon Sep 17 00:00:00 2001 From: Cloudygetty Date: Sat, 16 May 2026 01:09:57 -0400 Subject: [PATCH 1/2] docs: add universal org-level specs.md --- specs.md | 2227 +++++++++++++++++++++++++----------------------------- 1 file changed, 1043 insertions(+), 1184 deletions(-) diff --git a/specs.md b/specs.md index 5afc24f..344572a 100644 --- a/specs.md +++ b/specs.md @@ -1,425 +1,306 @@ -# ANL — Full Implementation Specifications +# CLOUDYGETTY-AI — Universal GOD-MODE Specifications # Sentinel Engine v6.0 · ENTROPY-ZERO -# This document is the authoritative source for all ANL implementations. +# Applies to every repo in the cloudygetty-ai organization. -# Claude Code reads this file to reconstruct the full codebase. +# This is the single source of truth for all implementations, patterns, and contracts. -# Do not modify without updating version + changelog. +# Claude Code reads this file on every session across every repo. ----- ## HOW TO USE THIS FILE -This is a GOD-MODE spec file. Every implementation in the ANL codebase -is defined here with full code, schemas, contracts, and architecture decisions. +This file lives at the org level and is referenced by every repo's CLAUDE.md. +It defines the universal implementation patterns, stack contracts, and code +standards that apply across all cloudygetty-ai projects. + +Per-repo SPECS.md files extend this document with repo-specific implementations. Claude Code workflow: -1. Read CLAUDE.md — load operational persona + constraints -1. Read PROJECT.md — load file map + known issues -1. Read SPECS.md (this file) — load all implementations -1. Scaffold each file exactly as specified -1. Run npx tsc –noEmit after every file -1. Run git add . && git commit && git push after full scaffold +1. Read CLAUDE.md — load operational persona + repo constraints +1. Read this file — load universal patterns + org standards +1. Read PROJECT.md — load repo-specific state + file map +1. Read repo SPECS.md (if exists) — load repo-specific implementations +1. Scaffold, implement, verify, commit, push ----- -## ENVIRONMENT VALIDATION - -### src/config/env.ts - -```typescript -import { z } from 'zod' - -const EnvSchema = z.object({ - DATABASE_URL: z.string().url(), - DIRECT_URL: z.string().url(), - SUPABASE_URL: z.string().url(), - SUPABASE_ANON_KEY: z.string().min(1), - SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), - SUPABASE_JWT_SECRET: z.string().min(1), - LIVEKIT_API_KEY: z.string().min(1), - LIVEKIT_API_SECRET: z.string().min(1), - LIVEKIT_WS_URL: z.string().url(), - AWS_ACCESS_KEY_ID: z.string().min(1), - AWS_SECRET_ACCESS_KEY: z.string().min(1), - AWS_REGION: z.string().min(1), - AWS_S3_BUCKET: z.string().min(1), - STRIPE_SECRET_KEY: z.string().min(1), - STRIPE_WEBHOOK_SECRET: z.string().min(1), - REVEAL_HMAC_SECRET: z.string().min(32), - PORT: z.coerce.number().default(3000), - NODE_ENV: z.enum(['development', 'production', 'test']), - JWT_SECRET: z.string().min(32), - CORS_ORIGIN: z.string().url(), -}) +## ORG REGISTRY -const parsed = EnvSchema.safeParse(process.env) - -if (!parsed.success) { - console.error('ENV VALIDATION FAILED — API will not start') - console.error(parsed.error.format()) - process.exit(1) -} - -export const env = parsed.data +``` +cloudygetty-ai — active repositories: + +ACTIVE — in production or active development +├── ANL AllNightLong dating app +│ RN/Expo + Express + PostGIS + Socket.io + LiveKit + Stripe +│ 5 patent-candidate features · Fly.io deploy +│ +├── game-on Real-money skill gaming platform +│ RN/Expo + Express monorepo · 8 games · Socket.io rooms +│ Stripe wallet · KYC · Tournament system · Fly.io +│ +├── VAULT WireGuard VPN app +│ RN client + Web dashboard + bash server bootstrap +│ Stripe billing · keypair registration · kill switch +│ +├── luminary AI image generation playground +│ Gemini via Vercel AI Gateway · batch gen · style presets +│ LLM prompt enhancement · A/B comparison · inpainting +│ +├── FORGE Groq-backed LLM chat UI +│ Llama 3.3 70B · dark luxury terminal aesthetic +│ Obsidian/gold · Cinzel · DM Mono · system prompt tuned +│ +├── don't-reneg-on-me Spades card game (formerly SpadesRoyale) +│ Vite/React + RN · dark luxury casino UI +│ Cinzel Decorative · obsidian/felt palette · gold shimmer +│ +├── open-up-app Social app +│ React/Node/Prisma/JWT · Next.js · Vercel +│ +├── crowned-lion PWA social casino +│ Plain JS · HTML5 Canvas · NJ-compliant · no framework +│ +├── precrime Static analysis + correctness engine +│ Go orchestration · Rust hot-path · TS/Go/Python targets +│ Contract Graph · Suspicion Engine · Reality Check layer +│ FinTech primary market · Audit/Gate/Org tiers +│ +├── echo Programming language + Vite plugin +│ esbuild compiler · JSX-like syntax · $ reactive state +│ Outputs: React Native components or plain JS/TS +│ +├── CLAUDE Autonomous dev environment +│ RN/TS · Zustand · Entropy-Zero V3.0 +│ Self-healing protocols · strict modularity +│ +├── vaultify Auth-as-a-service +│ JWT/OAuth/Redis/Prisma · proprietary platform +│ +├── PROXM Location-based discovery +│ PostGIS · view-once · 2FA +│ +├── hole-eaters Location social +│ WebRTC · Claude API integration +│ +└── zero-to-one Startup SPA + ConvertKit · Lemon Squeezy ``` ----- -## DATABASE +## UNIVERSAL STACK -### src/lib/prisma.ts +### Languages -```typescript -import { PrismaClient } from '@prisma/client' -import { env } from '../config/env' - -const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } - -export const prisma = - globalForPrisma.prisma ?? - new PrismaClient({ - log: env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], - }) - -if (env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma ``` - -### src/lib/supabase.ts - -```typescript -import { createClient } from '@supabase/supabase-js' -import { env } from '../config/env' - -export const supabaseAdmin = createClient( - env.SUPABASE_URL, - env.SUPABASE_SERVICE_ROLE_KEY -) +TypeScript 5.x strict mode — all repos except: + crowned-lion → plain JS only + precrime → Go (orchestration) + Rust (hot-path) + TS (client) + echo → TypeScript (compiler output configurable) ``` ------ - -## PRISMA SCHEMA - -### prisma/schema.prisma - -```prisma -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - directUrl = env("DIRECT_URL") -} - -model User { - id String @id @default(uuid()) - displayName String? - fullName String? - bio String? - gender String? - preference String[] - photos String[] - vibeTags String[] - phone String? - isVisible Boolean @default(true) - isOnline Boolean @default(false) - lastSeen DateTime @default(now()) - pushEnabled Boolean @default(true) - expoPushToken String? - stripeCustomerId String? - subscriptionStatus String? - createdAt DateTime @default(now()) - - activityEvents ActivityEvent[] - circadianProfile CircadianProfile? - venueVisits VenueVisit[] - venueAffinities VenueAffinityProfile[] - voiceProfile VoiceProfile? - revealsSent RevealCommitment[] @relation("RevealsSent") - revealsReceived RevealCommitment[] @relation("RevealsReceived") - contactHashes ContactHash[] - exclusionsSent SocialExclusion[] @relation("ExclusionsSent") - exclusionsReceived SocialExclusion[] @relation("ExclusionsReceived") - matchesAs1 Match[] @relation("MatchUser1") - matchesAs2 Match[] @relation("MatchUser2") - messagesSent Message[] @relation("MessageSender") - messagesReceived Message[] @relation("MessageRecipient") - payments Payment[] - - @@map("users") -} - -model Match { - id String @id @default(uuid()) - userId1 String - userId2 String - status String @default("active") - createdAt DateTime @default(now()) - - user1 User @relation("MatchUser1", fields: [userId1], references: [id], onDelete: Cascade) - user2 User @relation("MatchUser2", fields: [userId2], references: [id], onDelete: Cascade) - messages Message[] - - @@map("matches") -} - -model Message { - id String @id @default(uuid()) - matchId String - senderId String - recipientId String - content String - type String @default("text") - mediaUrl String? - readAt DateTime? - createdAt DateTime @default(now()) - - match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) - sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade) - recipient User @relation("MessageRecipient", fields: [recipientId], references: [id], onDelete: Cascade) - - @@map("messages") -} - -model Payment { - id String @id @default(uuid()) - userId String - stripePaymentIntentId String @unique - status String - amount Int - currency String - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("payments") -} +### Frontend -model ActivityEvent { - id String @id @default(uuid()) - userId String - eventType String - hourOfDay Int - dayOfWeek Int - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, createdAt(sort: Desc)]) - @@index([userId, hourOfDay]) - @@map("activity_events") -} - -model CircadianProfile { - userId String @id - activityVector Float[] - peakHours Int[] - confidence Float @default(0) - eventCount Int @default(0) - lastComputedAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("circadian_profiles") -} +``` +Mobile: React Native + Expo SDK 51+ +Web: React 18 + Vite or Next.js 14+ +State: Zustand +Data: TanStack Query v5 +Validation: Zod +Animation: Reanimated 3 (RN) / Framer Motion (web) +``` -model Venue { - id String @id @default(uuid()) - name String - category String - radiusM Int @default(100) - address String? - city String? - createdAt DateTime @default(now()) +### Backend - visits VenueVisit[] - affinities VenueAffinityProfile[] +``` +Runtime: Node.js 20 LTS +Framework: Express (primary) / Fastify (performance-critical) +ORM: Prisma 5+ +DB: PostgreSQL 15 + PostGIS 3.3 +Cache: Redis 7 +Auth: Supabase Auth (primary) / Vaultify (internal) +Realtime: Socket.io 4 (sticky sessions required on Fly) +Storage: AWS S3 (presigned URLs — never serve directly) +Payments: Stripe +Video: LiveKit WebRTC +Push: Expo Notifications (mobile) +``` - @@map("venues") -} +### Infrastructure -model VenueVisit { - id String @id @default(uuid()) - userId String - venueId String - arrivedAt DateTime @default(now()) - departedAt DateTime? - hourOfDay Int - dayOfWeek Int - confirmed Boolean @default(false) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) - - @@index([userId, arrivedAt(sort: Desc)]) - @@index([venueId, arrivedAt(sort: Desc)]) - @@map("venue_visits") -} +``` +API deploy: Fly.io (primary) — region ewr (Newark) +Web deploy: Vercel +DB: Supabase (PostgreSQL + PostGIS) +CI/CD: GitHub Actions +Monitoring: Sentry (errors) + PostHog (product analytics) +Container: Docker multi-stage non-root +``` -model VenueAffinityProfile { - userId String - venueId String - visitCount Int @default(0) - weightedScore Float @default(0) - peakHours Int[] - lastVisitedAt DateTime? - lastComputedAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) - - @@id([userId, venueId]) - @@index([userId]) - @@map("venue_affinity_profiles") -} +### Testing -model VoiceProfile { - userId String @id - mfccVector Float[] - pitchMean Float? - pitchRange Float? - pitchVariance Float? - speechRate Float? - energyMean Float? - energyVariance Float? - sampleS3Key String? - sampleDurationS Float? - confidence Float @default(0) - consentGranted Boolean @default(false) - consentAt DateTime? - recordedAt DateTime? - lastComputedAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("voice_profiles") -} +``` +Unit: Vitest (preferred) / Jest +E2E: Playwright (web) / Detox (RN) +API: Supertest +``` -model RevealCommitment { - id String @id @default(uuid()) - userId String - targetId String - commitment String - status String @default("pending") - expiresAt DateTime - createdAt DateTime @default(now()) - revealedAt DateTime? - - user User @relation("RevealsSent", fields: [userId], references: [id], onDelete: Cascade) - target User @relation("RevealsReceived", fields: [targetId], references: [id], onDelete: Cascade) - - @@unique([userId, targetId]) - @@index([targetId, status]) - @@map("reveal_commitments") -} +----- -model RevealAuditLog { - id String @id @default(uuid()) - eventType String - userId String - targetId String - createdAt DateTime @default(now()) +## UNIVERSAL AESTHETIC SYSTEM - @@map("reveal_audit_log") -} +Applies to ALL UI output across ALL repos. Non-negotiable. -model ContactHash { - id String @id @default(uuid()) - userId String - hash String - uploadedAt DateTime @default(now()) +```typescript +// Universal design tokens — extend per repo, never override core values - user User @relation(fields: [userId], references: [id], onDelete: Cascade) +export const colors = { + // Core — obsidian base + bg: '#0D0A14', // primary background + bgElevated: '#13101C', // elevated surface + bgCard: '#1A1625', // card/panel surface + border: '#2A2040', // primary border + borderSubtle: '#1E1830', // subtle separator + + // Accents — gold or violet (repo-specific) + gold: '#C9A84C', // ANL, FORGE, VAULT, game-on + violet: '#8B5CF6', // ANL alternate, CLAUDE + crimson: '#9B2335', // don't-reneg-on-me (felt/casino) + emerald: '#10B981', // success states universal + + // Text + text: '#F0EBF8', // primary text + textMuted: '#7B6B9A', // secondary text + textDim: '#4A3D66', // disabled/placeholder + + // Semantic + danger: '#EF4444', + warning: '#F59E0B', + success: '#10B981', + info: '#3B82F6', +} as const - @@unique([userId, hash]) - @@index([hash]) - @@map("contact_hashes") -} +export const fonts = { + display: 'Cinzel-Regular', // headings, wordmarks + displayB: 'Cinzel-Bold', // hero text, scores + ui: 'DMMono-Regular', // body, labels, data + uiM: 'DMMono-Medium', // buttons, badges + // don't-reneg-on-me override: + casino: 'Cinzel Decorative', // card game titles +} as const -model SocialExclusion { - id String @id @default(uuid()) - userId String - excludedId String - reason String - expiresAt DateTime? - createdAt DateTime @default(now()) +export const spacing = { + xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, +} as const - user User @relation("ExclusionsSent", fields: [userId], references: [id], onDelete: Cascade) - excluded User @relation("ExclusionsReceived", fields: [excludedId], references: [id], onDelete: Cascade) +export const radius = { + sm: 4, md: 8, lg: 16, full: 9999, +} as const - @@unique([userId, excludedId]) - @@index([userId]) - @@map("social_exclusions") -} +export const shadows = { + card: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 4 }, + glow: (color: string) => ({ shadowColor: color, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.6, shadowRadius: 12, elevation: 8 }), +} as const ``` ----- -## MIDDLEWARE +## UNIVERSAL AUTH PATTERN + +All repos use this pattern. Stack may vary (Supabase / Vaultify / JWT) but contracts are identical. -### src/middleware/auth.ts +### REST Auth Middleware ```typescript +// src/middleware/auth.ts — universal pattern import { Request, Response, NextFunction } from 'express' -import { supabaseAdmin } from '../lib/supabase' export interface AuthRequest extends Request { userId: string userEmail: string } -export async function requireAuth(req: Request, res: Response, next: NextFunction) { +// Implementation varies by repo auth provider: +// Supabase: supabaseAdmin.auth.getUser(token) +// Vaultify: vaultify.verify(token) +// JWT: jwt.verify(token, env.JWT_SECRET) +export async function requireAuth( + req: Request, res: Response, next: NextFunction +) { const authHeader = req.headers.authorization if (!authHeader?.startsWith('Bearer ')) { return res.status(401).json({ error: 'Missing authorization header' }) } const token = authHeader.slice(7) - const { data, error } = await supabaseAdmin.auth.getUser(token) - if (error || !data.user) { - return res.status(401).json({ error: 'Invalid or expired token' }) - } - ;(req as AuthRequest).userId = data.user.id - ;(req as AuthRequest).userEmail = data.user.email ?? '' - next() + // verify token with repo auth provider + // attach userId and userEmail to request + // call next() or return 401 } ``` -### src/middleware/socketAuth.ts +### Socket.io Auth Middleware ```typescript +// src/middleware/socketAuth.ts — universal pattern import { Socket } from 'socket.io' -import { supabaseAdmin } from '../lib/supabase' export interface AuthSocket extends Socket { userId: string } export async function socketAuthMiddleware( - socket: Socket, - next: (err?: Error) => void + socket: Socket, next: (err?: Error) => void ) { const token = socket.handshake.auth?.token as string | undefined if (!token) return next(new Error('AUTH_MISSING')) - const { data, error } = await supabaseAdmin.auth.getUser(token) - if (error || !data.user) return next(new Error('AUTH_INVALID')) - ;(socket as AuthSocket).userId = data.user.id - next() + // verify token — same provider as REST + // attach userId to socket + // call next() or next(new Error('AUTH_INVALID')) +} +``` + +----- + +## UNIVERSAL ENV VALIDATION + +All repos validate environment at boot. App never starts with missing vars. + +```typescript +// src/config/env.ts — universal pattern +import { z } from 'zod' + +// Define schema with all required vars for this repo +const EnvSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']), + PORT: z.coerce.number().default(3000), + // ... repo-specific vars +}) + +const parsed = EnvSchema.safeParse(process.env) + +if (!parsed.success) { + console.error('ENV VALIDATION FAILED — server will not start') + console.error(parsed.error.format()) + process.exit(1) } + +export const env = parsed.data ``` -### src/middleware/rateLimit.ts +----- + +## UNIVERSAL RATE LIMITING ```typescript +// src/middleware/rateLimit.ts — universal pattern import rateLimit from 'express-rate-limit' export const globalLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, + windowMs: 15 * 60 * 1000, // 15 min max: 300, standardHeaders: true, legacyHeaders: false, @@ -432,639 +313,427 @@ export const authLimiter = rateLimit({ message: { error: 'Too many auth attempts' }, }) -export const mediaLimiter = rateLimit({ - windowMs: 60 * 1000, - max: 30, - message: { error: 'Media request limit exceeded' }, -}) +// Add repo-specific limiters as needed +// e.g. mediaLimiter, aiLimiter, gameLimiter ``` ----- -## PATENT FEATURE SERVICES +## UNIVERSAL HEALTH ENDPOINT -### PATENT 1 — src/services/circadian.service.ts +Every repo exposes `/health`. Required for Fly.io healthcheck. ```typescript +// src/routes/health.ts — universal pattern +import { Router } from 'express' import { prisma } from '../lib/prisma' +import { env } from '../config/env' -const WINDOW_DAYS = 14 -const MIN_EVENTS_FOR_CONFIDENCE = 20 -const PEAK_HOUR_THRESHOLD = 0.7 -const STALE_HOURS = 6 - -export type ActivityEventType = - | 'foreground' | 'background' | 'location' | 'message_sent' | 'profile_view' +const router = Router() -export async function logActivityEvent(userId: string, eventType: ActivityEventType) { - const now = new Date() - await prisma.activityEvent.create({ - data: { userId, eventType, hourOfDay: now.getUTCHours(), dayOfWeek: now.getUTCDay() }, - }) -} +router.get('/health', async (_req, res) => { + const start = Date.now() + try { + await prisma.$queryRaw`SELECT 1` + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString(), + env: env.NODE_ENV, + services: { db: { status: 'ok', latency_ms: Date.now() - start } }, + }) + } catch (err) { + res.status(503).json({ + status: 'degraded', + timestamp: new Date().toISOString(), + services: { db: { status: 'error', error: (err as Error).message } }, + }) + } +}) -function buildActivityVector(events: { hourOfDay: number }[]): number[] { - const counts = new Array(24).fill(0) - for (const { hourOfDay } of events) counts[hourOfDay]++ - const max = Math.max(...counts) - if (max === 0) return counts - return counts.map(c => c / max) -} +export default router +``` -function extractPeakHours(vector: number[]): number[] { - return vector - .map((weight, hour) => ({ hour, weight })) - .filter(({ weight }) => weight >= PEAK_HOUR_THRESHOLD) - .sort((a, b) => b.weight - a.weight) - .map(({ hour }) => hour) -} +----- -function computeConfidence(eventCount: number): number { - if (eventCount === 0) return 0 - return Math.min(1.0, Math.log(eventCount + 1) / Math.log(MIN_EVENTS_FOR_CONFIDENCE + 1)) -} +## UNIVERSAL PRISMA PATTERN -export async function recomputeProfile(userId: string) { - const since = new Date(Date.now() - WINDOW_DAYS * 86400000) - const events = await prisma.activityEvent.findMany({ - where: { userId, createdAt: { gte: since } }, - select: { hourOfDay: true }, - }) - const vector = buildActivityVector(events) - const peakHours = extractPeakHours(vector) - const confidence = computeConfidence(events.length) - await prisma.circadianProfile.upsert({ - where: { userId }, - create: { userId, activityVector: vector, peakHours, confidence, eventCount: events.length }, - update: { activityVector: vector, peakHours, confidence, eventCount: events.length, lastComputedAt: new Date() }, - }) - return { activityVector: vector, peakHours, confidence } -} +```typescript +// src/lib/prisma.ts — universal pattern +import { PrismaClient } from '@prisma/client' +import { env } from '../config/env' -export async function getOrComputeProfile(userId: string) { - const existing = await prisma.circadianProfile.findUnique({ where: { userId } }) - const staleThreshold = new Date(Date.now() - STALE_HOURS * 3600000) - if (existing && existing.lastComputedAt > staleThreshold) { - return { activityVector: existing.activityVector, peakHours: existing.peakHours, confidence: existing.confidence } - } - return recomputeProfile(userId) -} +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } -function cosineSim(a: number[], b: number[]): number { - const dot = a.reduce((s, ai, i) => s + ai * (b[i] ?? 0), 0) - const magA = Math.sqrt(a.reduce((s, ai) => s + ai * ai, 0)) - const magB = Math.sqrt(b.reduce((s, bi) => s + bi * bi, 0)) - if (magA === 0 || magB === 0) return 0 - return dot / (magA * magB) -} +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }) -export async function computeCCS(userAId: string, userBId: string): Promise { - const [pA, pB] = await Promise.all([getOrComputeProfile(userAId), getOrComputeProfile(userBId)]) - if (pA.confidence < 0.1 || pB.confidence < 0.1) return 0.5 - const raw = cosineSim(pA.activityVector, pB.activityVector) - const normalized = (raw + 1) / 2 - const weight = (pA.confidence + pB.confidence) / 2 - return 0.5 + (normalized - 0.5) * weight -} +if (env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma ``` -### PATENT 2 — src/services/venue.service.ts +----- + +## UNIVERSAL EXPRESS APP PATTERN ```typescript -import { prisma } from '../lib/prisma' +// src/app.ts — universal pattern +import express from 'express' +import helmet from 'helmet' +import cors from 'cors' +import { globalLimiter } from './middleware/rateLimit' +import { env } from './config/env' -const DWELL_CONFIRM_MIN = 5 -const AFFINITY_DECAY_HALF_LIFE = 30 -const WINDOW_DAYS = 90 - -export async function detectVenue(userId: string, lat: number, lng: number): Promise { - const results = await prisma.$queryRaw<{ id: string }[]>` - SELECT id FROM venues - WHERE ST_DWithin( - location::geography, - ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, - LEAST(radius_m, 200) - ) - ORDER BY ST_Distance(location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) ASC - LIMIT 1 - ` - return results[0]?.id ?? null -} +const app = express() -export async function processLocationForVenue(userId: string, lat: number, lng: number) { - const now = new Date() - const venueId = await detectVenue(userId, lat, lng) - const openVisit = await prisma.venueVisit.findFirst({ - where: { userId, departedAt: null }, - orderBy: { arrivedAt: 'desc' }, - }) - if (venueId) { - if (openVisit?.venueId === venueId) { - const dwellMin = (now.getTime() - openVisit.arrivedAt.getTime()) / 60000 - if (!openVisit.confirmed && dwellMin >= DWELL_CONFIRM_MIN) { - await prisma.venueVisit.update({ where: { id: openVisit.id }, data: { confirmed: true } }) - await updateVenueAffinity(userId, venueId, openVisit.hourOfDay) - } - } else { - if (openVisit) await prisma.venueVisit.update({ where: { id: openVisit.id }, data: { departedAt: now } }) - await prisma.venueVisit.create({ - data: { userId, venueId, arrivedAt: now, hourOfDay: now.getUTCHours(), dayOfWeek: now.getUTCDay() }, - }) - } - } else if (openVisit) { - await prisma.venueVisit.update({ where: { id: openVisit.id }, data: { departedAt: now } }) - } -} +app.use(helmet()) +app.use(cors({ origin: env.CORS_ORIGIN, credentials: true })) -async function updateVenueAffinity(userId: string, venueId: string, hourOfDay: number) { - const lambda = Math.LN2 / AFFINITY_DECAY_HALF_LIFE - const now = new Date() - const since = new Date(Date.now() - WINDOW_DAYS * 86400000) - const visits = await prisma.venueVisit.findMany({ - where: { userId, venueId, confirmed: true, arrivedAt: { gte: since } }, - select: { arrivedAt: true, hourOfDay: true }, - }) - if (visits.length === 0) return - const weightedScore = visits.reduce((sum, v) => { - const ageDays = (now.getTime() - v.arrivedAt.getTime()) / 86400000 - return sum + Math.exp(-lambda * ageDays) - }, 0) - const hourCounts = new Array(24).fill(0) - visits.forEach(v => hourCounts[v.hourOfDay]++) - const maxCount = Math.max(...hourCounts) - const peakHours = hourCounts.map((c, h) => ({ h, c })).filter(({ c }) => maxCount > 0 && c / maxCount >= 0.6).map(({ h }) => h) - await prisma.venueAffinityProfile.upsert({ - where: { userId_venueId: { userId, venueId } }, - create: { userId, venueId, visitCount: visits.length, weightedScore, peakHours, lastVisitedAt: now }, - update: { visitCount: visits.length, weightedScore, peakHours, lastVisitedAt: now, lastComputedAt: now }, - }) -} +// Stripe webhook must come BEFORE express.json() +// app.use('/payments/webhook', express.raw({ type: 'application/json' })) -export async function computeVAS(userAId: string, userBId: string): Promise { - const [pA, pB] = await Promise.all([ - prisma.venueAffinityProfile.findMany({ where: { userId: userAId } }), - prisma.venueAffinityProfile.findMany({ where: { userId: userBId } }), - ]) - if (pA.length === 0 || pB.length === 0) return 0.5 - const mapA = new Map(pA.map(p => [p.venueId, p.weightedScore])) - const mapB = new Map(pB.map(p => [p.venueId, p.weightedScore])) - const shared = [...mapA.keys()].filter(id => mapB.has(id)) - if (shared.length === 0) return 0.5 - const dot = shared.reduce((s, id) => s + (mapA.get(id) ?? 0) * (mapB.get(id) ?? 0), 0) - const magA = Math.sqrt([...mapA.values()].reduce((s, v) => s + v * v, 0)) - const magB = Math.sqrt([...mapB.values()].reduce((s, v) => s + v * v, 0)) - if (magA === 0 || magB === 0) return 0.5 - return Math.min(1.0, dot / (magA * magB)) -} +app.use(express.json({ limit: '10kb' })) +app.use(globalLimiter) -export async function getVenueHeatmap(lat: number, lng: number, radiusM = 10000) { - const results = await prisma.$queryRaw<{ venue_id: string; lat: number; lng: number; active_count: bigint }[]>` - SELECT v.id AS venue_id, ST_Y(v.location::geometry) AS lat, ST_X(v.location::geometry) AS lng, - COUNT(vv.id) AS active_count - FROM venues v - LEFT JOIN venue_visits vv ON vv.venue_id = v.id AND vv.departed_at IS NULL - AND vv.confirmed = true AND vv.arrived_at > NOW() - INTERVAL '2 hours' - WHERE ST_DWithin(v.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusM}) - GROUP BY v.id - ORDER BY active_count DESC - ` - const maxCount = Math.max(1, ...results.map(r => Number(r.active_count))) - return results.map(r => ({ - venueId: r.venue_id, lat: r.lat, lng: r.lng, - activeCount: Number(r.active_count), - intensity: Number(r.active_count) / maxCount, - })) -} +// Mount routes here + +app.use((_req, res) => res.status(404).json({ error: 'Not found' })) +app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('[Express]', err.message) + res.status(500).json({ error: 'Internal server error' }) +}) + +export default app ``` -### PATENT 3 — src/services/voice.service.ts +----- + +## UNIVERSAL SERVER ENTRY POINT ```typescript -import { prisma } from '../lib/prisma' +// src/index.ts — universal pattern +import './config/env' +import http from 'http' +import app from './app' +import { env } from './config/env' -const STALENESS_DAYS = 30 - -export interface AcousticFeatures { - mfccVector: number[] - pitchMean: number - pitchRange: number - pitchVariance: number - speechRate: number - energyMean: number - energyVariance: number - confidence: number -} +const httpServer = http.createServer(app) -export async function storeVoiceProfile( - userId: string, features: AcousticFeatures, s3Key: string, durationS: number -) { - await prisma.voiceProfile.upsert({ - where: { userId }, - create: { - userId, ...features, sampleS3Key: s3Key, sampleDurationS: durationS, - consentGranted: true, consentAt: new Date(), recordedAt: new Date(), - }, - update: { - ...features, sampleS3Key: s3Key, sampleDurationS: durationS, - recordedAt: new Date(), lastComputedAt: new Date(), - }, - }) -} +// Optional: attach Socket.io +// import { createSocketServer } from './socket' +// const io = createSocketServer(httpServer) +// app.set('io', io) -async function getActiveProfile(userId: string) { - const p = await prisma.voiceProfile.findUnique({ where: { userId } }) - if (!p || !p.consentGranted || !p.recordedAt) return null - const ageDays = (Date.now() - p.recordedAt.getTime()) / 86400000 - if (ageDays > STALENESS_DAYS) { - const decay = Math.max(0, 1 - (ageDays - STALENESS_DAYS) / STALENESS_DAYS) - return { ...p, confidence: p.confidence * decay } - } - return p -} +httpServer.listen(env.PORT, () => { + console.log(`[${process.env.npm_package_name}] [${env.NODE_ENV}] port ${env.PORT}`) +}) -function cosineSim(a: number[], b: number[]): number { - if (a.length !== b.length || a.length === 0) return 0 - const dot = a.reduce((s, ai, i) => s + ai * (b[i] ?? 0), 0) - const magA = Math.sqrt(a.reduce((s, ai) => s + ai * ai, 0)) - const magB = Math.sqrt(b.reduce((s, bi) => s + bi * bi, 0)) - if (magA === 0 || magB === 0) return 0 - return dot / (magA * magB) +const shutdown = (signal: string) => { + console.log(`${signal} — graceful shutdown`) + httpServer.close(() => process.exit(0)) + setTimeout(() => process.exit(1), 10000) } -export async function computeVCS(userAId: string, userBId: string): Promise { - const [pA, pB] = await Promise.all([getActiveProfile(userAId), getActiveProfile(userBId)]) - if (!pA || !pB || pA.confidence < 0.1 || pB.confidence < 0.1) return 0.5 - const mfccRaw = cosineSim(pA.mfccVector, pB.mfccVector) - const mfccScore = (mfccRaw + 1) / 2 - const aMin = (pA.pitchMean ?? 0) - (pA.pitchRange ?? 0) / 2 - const aMax = (pA.pitchMean ?? 0) + (pA.pitchRange ?? 0) / 2 - const bMin = (pB.pitchMean ?? 0) - (pB.pitchRange ?? 0) / 2 - const bMax = (pB.pitchMean ?? 0) + (pB.pitchRange ?? 0) / 2 - const overlapLen = Math.max(0, Math.min(aMax, bMax) - Math.max(aMin, bMin)) - const unionLen = Math.max(aMax, bMax) - Math.min(aMin, bMin) - const pitchScore = unionLen > 0 ? overlapLen / unionLen : 0.5 - const rateDelta = Math.abs((pA.speechRate ?? 0) - (pB.speechRate ?? 0)) - const rateScore = Math.max(0, 1 - rateDelta / 4) - const energyDelta = Math.abs((pA.energyMean ?? 0) - (pB.energyMean ?? 0)) - const energyScore = Math.max(0, 1 - energyDelta / 0.5) - const rawVCS = mfccScore * 0.50 + pitchScore * 0.25 + rateScore * 0.15 + energyScore * 0.10 - const weight = (pA.confidence + pB.confidence) / 2 - return 0.5 + (rawVCS - 0.5) * weight -} +process.on('SIGTERM', () => shutdown('SIGTERM')) +process.on('SIGINT', () => shutdown('SIGINT')) ``` -### PATENT 4 — src/services/reveal.service.ts +----- -```typescript -import { createHmac, randomBytes, createCipheriv, createDecipheriv } from 'crypto' -import { prisma } from '../lib/prisma' -import { env } from '../config/env' +## UNIVERSAL DOCKERFILE -const REVEAL_TTL_HOURS = 24 -const AES_ALGO = 'aes-256-gcm' +```dockerfile +# Multi-stage · non-root · Alpine · OpenSSL for Prisma +FROM node:20-alpine AS builder +WORKDIR /app +RUN apk add --no-cache openssl +COPY package*.json ./ +RUN npm ci --ignore-scripts +COPY . . +RUN npx prisma generate +RUN npm run build -function generateCommitment(userId: string, targetId: string, nonce: string): string { - return createHmac('sha256', env.REVEAL_HMAC_SECRET) - .update(`${userId}:${targetId}:${nonce}`) - .digest('hex') -} +FROM node:20-alpine AS runner +WORKDIR /app +RUN apk add --no-cache openssl +RUN addgroup -S app && adduser -S app -G app +COPY --from=builder --chown=app:app /app/dist ./dist +COPY --from=builder --chown=app:app /app/node_modules ./node_modules +COPY --from=builder --chown=app:app /app/prisma ./prisma +COPY --from=builder --chown=app:app /app/package.json ./ +USER app +EXPOSE 3000 +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] +``` -function encryptPayload(payload: object, key: Buffer): string { - const iv = randomBytes(12) - const cipher = createCipheriv(AES_ALGO, key, iv) - const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]) - const tag = cipher.getAuthTag() - return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}` -} +----- -function deriveRevealKey(userAId: string, userBId: string): Buffer { - const pairId = [userAId, userBId].sort().join(':') - return Buffer.from(createHmac('sha256', env.REVEAL_HMAC_SECRET).update(pairId).digest()) -} +## UNIVERSAL FLY.TOML -export async function submitCommitment(userId: string, targetId: string) { - const nonce = randomBytes(16).toString('hex') - const commitment = generateCommitment(userId, targetId, nonce) - const expiresAt = new Date(Date.now() + REVEAL_TTL_HOURS * 3600000) - await prisma.revealCommitment.upsert({ - where: { userId_targetId: { userId, targetId } }, - create: { userId, targetId, commitment, expiresAt }, - update: { commitment, expiresAt, status: 'pending' }, - }) - await prisma.revealAuditLog.create({ data: { eventType: 'committed', userId, targetId } }) - const counterpart = await prisma.revealCommitment.findUnique({ - where: { userId_targetId: { userId: targetId, targetId: userId } }, - }) - const isMutual = counterpart?.status === 'pending' && counterpart.expiresAt > new Date() - if (isMutual) { - await prisma.$transaction([ - prisma.revealCommitment.update({ where: { userId_targetId: { userId, targetId } }, data: { status: 'matched', revealedAt: new Date() } }), - prisma.revealCommitment.update({ where: { userId_targetId: { userId: targetId, targetId: userId } }, data: { status: 'matched', revealedAt: new Date() } }), - prisma.revealAuditLog.create({ data: { eventType: 'matched', userId, targetId } }), - ]) - } - return { committed: true, alreadyMutual: isMutual } -} +```toml +# Replace APP_NAME with repo name +app = "APP_NAME" +primary_region = "ewr" -export async function getRevealPayload(requesterId: string, targetId: string) { - const [mine, theirs] = await Promise.all([ - prisma.revealCommitment.findUnique({ where: { userId_targetId: { userId: requesterId, targetId } } }), - prisma.revealCommitment.findUnique({ where: { userId_targetId: { userId: targetId, targetId: requesterId } } }), - ]) - if (mine?.status !== 'matched' || theirs?.status !== 'matched') return null - const target = await prisma.user.findUnique({ - where: { id: targetId }, - select: { displayName: true, fullName: true, photos: true }, - }) - if (!target) return null - const key = deriveRevealKey(requesterId, targetId) - return { encrypted: encryptPayload(target, key) } -} +[build] + dockerfile = "Dockerfile" + +[env] + PORT = "3000" + NODE_ENV = "production" + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = false + auto_start_machines = true + min_machines_running = 1 + + [http_service.concurrency] + type = "connections" + hard_limit = 500 + soft_limit = 200 + +[[vm]] + memory = "512mb" + cpu_kind = "shared" + cpus = 1 + +[checks] + [checks.health] + grace_period = "10s" + interval = "30s" + method = "GET" + path = "/health" + port = 3000 + timeout = "5s" + type = "http" +``` + +----- + +## UNIVERSAL GITHUB ACTIONS + +```yaml +# .github/workflows/deploy.yml — universal pattern +name: Deploy +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + - run: npx prisma generate + - run: npm run typecheck + - run: npm run lint + - run: npm test + env: + NODE_ENV: test + # repo-specific test env vars + + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} +``` -export async function withdrawCommitment(userId: string, targetId: string) { - await prisma.revealCommitment.updateMany({ where: { userId, targetId, status: 'pending' }, data: { status: 'withdrawn' } }) - await prisma.revealAuditLog.create({ data: { eventType: 'withdrawn', userId, targetId } }) -} +----- -export async function expireStaleCommitments(): Promise { - const result = await prisma.revealCommitment.updateMany({ - where: { status: 'pending', expiresAt: { lt: new Date() } }, - data: { status: 'expired' }, - }) - return result.count -} -``` +## UNIVERSAL ERROR BOUNDARY (React Native) -### PATENT 5 — src/services/socialGraph.service.ts +Required on every screen root in every RN repo. ```typescript -import { createHash } from 'crypto' -import { prisma } from '../lib/prisma' +// src/components/ErrorBoundary.tsx — universal +import React, { Component, ReactNode } from 'react' +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native' -function hashPhone(phone: string): string { - const normalized = phone.replace(/\D/g, '') - const e164 = normalized.startsWith('1') ? `+${normalized}` : `+1${normalized}` - return createHash('sha256').update(e164).digest('hex') -} +interface Props { children: ReactNode; fallback?: ReactNode; onError?: (error: Error) => void } +interface State { hasError: boolean; error: Error | null } -export async function uploadContacts(userId: string, rawPhones: string[]) { - const hashes = [...new Set(rawPhones.map(hashPhone))] - await prisma.$executeRaw` - INSERT INTO contact_hashes (user_id, hash) - SELECT ${userId}::uuid, unnest(${hashes}::text[]) - ON CONFLICT (user_id, hash) DO NOTHING - ` - const myProfile = await prisma.user.findUnique({ where: { id: userId }, select: { phone: true } }) - let contactMatches = 0 - if (myProfile?.phone) { - const myHash = hashPhone(myProfile.phone) - const usersWithMyHash = await prisma.contactHash.findMany({ where: { hash: myHash, userId: { not: userId } }, select: { userId: true } }) - for (const { userId: otherId } of usersWithMyHash) { - await prisma.socialExclusion.upsert({ where: { userId_excludedId: { userId, excludedId: otherId } }, create: { userId, excludedId: otherId, reason: 'contact_match' }, update: {} }) - await prisma.socialExclusion.upsert({ where: { userId_excludedId: { userId: otherId, excludedId: userId } }, create: { userId: otherId, excludedId: userId, reason: 'contact_match' }, update: {} }) - contactMatches++ - } - } - return { matched: contactMatches, uploaded: hashes.length } -} +export class ErrorBoundary extends Component { + state: State = { hasError: false, error: null } -export async function getExcludedIds(userId: string): Promise> { - const exclusions = await prisma.socialExclusion.findMany({ - where: { userId, OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] }, - select: { excludedId: true }, - }) - return new Set(exclusions.map(e => e.excludedId)) -} + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } -export async function getMutualConnectionIds(userId: string): Promise> { - const myReveals = await prisma.revealCommitment.findMany({ where: { userId, status: 'matched' }, select: { targetId: true } }) - if (myReveals.length === 0) return new Set() - const myRevealedIds = myReveals.map(r => r.targetId) - const mutuals = await prisma.revealCommitment.findMany({ - where: { userId: { in: myRevealedIds }, status: 'matched', targetId: { not: userId } }, - select: { targetId: true }, - }) - return new Set(mutuals.map(m => m.targetId)) -} + componentDidCatch(error: Error, info: React.ErrorInfo) { + console.error('[ErrorBoundary]', error.message, info.componentStack) + this.props.onError?.(error) + // TODO[P1]: wire to Sentry + } -export async function getFullExclusionSet(userId: string): Promise> { - const [explicit, mutual] = await Promise.all([getExcludedIds(userId), getMutualConnectionIds(userId)]) - return new Set([...explicit, ...mutual]) + reset = () => this.setState({ hasError: false, error: null }) + + render() { + if (this.state.hasError) { + return this.props.fallback ?? ( + + Something went wrong + {this.state.error?.message} + + Try Again + + + ) + } + return this.props.children + } } -export async function addExclusion(userId: string, excludedId: string, reason: 'block' | 'manual' = 'manual', ttlDays?: number) { - const expiresAt = ttlDays ? new Date(Date.now() + ttlDays * 86400000) : null - await prisma.socialExclusion.upsert({ where: { userId_excludedId: { userId, excludedId } }, create: { userId, excludedId, reason, expiresAt }, update: { reason, expiresAt } }) -} +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#0D0A14', alignItems: 'center', justifyContent: 'center', padding: 24 }, + title: { fontFamily: 'Cinzel-Regular', fontSize: 20, color: '#C9A84C', marginBottom: 12 }, + message: { fontFamily: 'DMMono-Regular', fontSize: 13, color: '#6B5B7B', textAlign: 'center', marginBottom: 32 }, + button: { borderWidth: 1, borderColor: '#C9A84C', paddingHorizontal: 32, paddingVertical: 12 }, + buttonText: { fontFamily: 'DMMono-Regular', fontSize: 13, color: '#C9A84C', letterSpacing: 2 }, +}) ``` ----- -## MATCHING PIPELINE - -### src/services/matching.service.ts +## UNIVERSAL API CLIENT (React Native / Web) ```typescript -import { getNearbyUsers } from './proximity.service' -import { computeCCS } from './circadian.service' -import { computeVAS } from './venue.service' -import { computeVCS } from './voice.service' -import { getFullExclusionSet } from './socialGraph.service' - -const WEIGHTS = { proximity: 0.35, ccs: 0.25, vas: 0.25, vcs: 0.15 } as const - -export interface ScoredMatch { - id: string - displayName: string - distanceMeters: number - circadianScore: number - venueAffinityScore: number - voiceChemistryScore: number - compatibilityScore: number -} +// src/lib/api.ts — universal pattern +const BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? '' -export async function getRankedMatches( - userId: string, lat: number, lng: number, radiusMeters = 5000, limit = 50 -): Promise { - const excluded = await getFullExclusionSet(userId) - const nearby = await getNearbyUsers({ userId, lat, lng, radiusMeters, limit }) - const eligible = nearby.filter(u => !excluded.has(u.id)) - if (eligible.length === 0) return [] - const scored = await Promise.all( - eligible.map(async (candidate) => { - const [ccs, vas, vcs] = await Promise.all([ - computeCCS(userId, candidate.id), - computeVAS(userId, candidate.id), - computeVCS(userId, candidate.id), - ]) - return { ...candidate, ccs, vas, vcs } - }) - ) - const maxDist = Math.max(1, ...scored.map(u => u.distance_meters)) - return scored - .map(u => { - const proxScore = 1 - u.distance_meters / maxDist - const vcms = proxScore * WEIGHTS.proximity + u.ccs * WEIGHTS.ccs + u.vas * WEIGHTS.vas + u.vcs * WEIGHTS.vcs - return { - id: u.id, displayName: u.displayName, - distanceMeters: Math.round(u.distance_meters), - circadianScore: Math.round(u.ccs * 100), - venueAffinityScore: Math.round(u.vas * 100), - voiceChemistryScore: Math.round(u.vcs * 100), - compatibilityScore: Math.round(vcms * 100), - } - }) - .sort((a, b) => b.compatibilityScore - a.compatibilityScore) -} -``` +class ApiClient { + private token: string | null = null -### src/services/proximity.service.ts + setToken(token: string) { this.token = token } + clearToken() { this.token = null } -```typescript -import { prisma } from '../lib/prisma' + private async request(method: string, path: string, body?: unknown): Promise { + const headers: Record = { 'Content-Type': 'application/json' } + if (this.token) headers['Authorization'] = `Bearer ${this.token}` -interface NearbyUser { - id: string - displayName: string - distance_meters: number - lastSeen: Date -} + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) -export async function getNearbyUsers({ userId, lat, lng, radiusMeters = 5000, limit = 50 }: { - userId: string; lat: number; lng: number; radiusMeters?: number; limit?: number -}): Promise { - return prisma.$queryRaw` - SELECT u.id, u."displayName", u."lastSeen", - ST_Distance(u.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters - FROM users u - WHERE u.id != ${userId} AND u."isVisible" = true AND u."lastSeen" > NOW() - INTERVAL '24 hours' - AND ST_DWithin(u.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters}) - ORDER BY distance_meters ASC LIMIT ${limit} - ` -} + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Request failed' })) + throw new Error(error.error ?? `HTTP ${res.status}`) + } + + return res.json() + } -export async function updateUserLocation(userId: string, lat: number, lng: number) { - await prisma.$executeRaw` - UPDATE users SET location = ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326), "lastSeen" = NOW() WHERE id = ${userId} - ` + get(path: string) { return this.request('GET', path) } + post(path: string, body?: unknown) { return this.request('POST', path, body) } + patch(path: string, body?: unknown) { return this.request('PATCH', path, body) } + delete(path: string) { return this.request('DELETE', path) } } + +export const api = new ApiClient() ``` ----- -## SOCKET.IO - -### src/socket/index.ts +## UNIVERSAL ZUSTAND STORE PATTERN ```typescript -import { Server } from 'socket.io' -import { Server as HttpServer } from 'http' -import { env } from '../config/env' -import { socketAuthMiddleware, AuthSocket } from '../middleware/socketAuth' -import { registerPresence } from './presence' -import { registerMessaging } from './messaging' - -export function createSocketServer(httpServer: HttpServer): Server { - const io = new Server(httpServer, { - cors: { origin: env.CORS_ORIGIN, credentials: true }, - transports: ['websocket', 'polling'], - pingTimeout: 20000, - pingInterval: 25000, - }) - io.use(socketAuthMiddleware) - io.on('connection', (socket) => { - const s = socket as AuthSocket - s.join(`user:${s.userId}`) - registerPresence(io, s) - registerMessaging(io, s) - }) - return io -} +// src/store/index.ts — universal pattern +import { create } from 'zustand' + +// Every store follows this shape: +// - state fields (typed) +// - actions (synchronous state updates) +// - async operations called from components, NOT stored as actions + +interface AppState { + // Auth + userId: string | null + token: string | null + + // UI + isLoading: boolean + error: string | null + + // Actions + setAuth: (userId: string, token: string) => void + clearAuth: () => void + setLoading: (loading: boolean) => void + setError: (error: string | null) => void +} + +export const useAppStore = create((set) => ({ + userId: null, + token: null, + isLoading: false, + error: null, + + setAuth: (userId, token) => set({ userId, token }), + clearAuth: () => set({ userId: null, token: null }), + setLoading: (isLoading) => set({ isLoading }), + setError: (error) => set({ error }), +})) ``` -### src/socket/presence.ts +----- -```typescript -import { Server } from 'socket.io' -import { AuthSocket } from '../middleware/socketAuth' -import { updateUserLocation } from '../services/proximity.service' -import { logActivityEvent } from '../services/circadian.service' -import { processLocationForVenue } from '../services/venue.service' -import { prisma } from '../lib/prisma' +## UNIVERSAL SOCKET.IO CLIENT HOOK -export function registerPresence(io: Server, socket: AuthSocket) { - const { userId } = socket - prisma.user.update({ where: { id: userId }, data: { isOnline: true, lastSeen: new Date() } }).catch(console.error) - socket.on('presence:location', async ({ lat, lng }: { lat: number; lng: number }) => { - if (typeof lat !== 'number' || typeof lng !== 'number') return - if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return - await Promise.all([ - updateUserLocation(userId, lat, lng), - logActivityEvent(userId, 'location'), - processLocationForVenue(userId, lat, lng), - ]).catch(console.error) - }) - socket.on('presence:visibility', async ({ visible }: { visible: boolean }) => { - await prisma.user.update({ where: { id: userId }, data: { isVisible: visible } }).catch(console.error) - }) - socket.on('app:foreground', () => logActivityEvent(userId, 'foreground').catch(console.error)) - socket.on('app:background', () => logActivityEvent(userId, 'background').catch(console.error)) - socket.on('disconnect', () => { - prisma.user.update({ where: { id: userId }, data: { isOnline: false, lastSeen: new Date() } }).catch(console.error) - }) -} -``` +```typescript +// src/hooks/useSocket.ts — universal pattern +import { useEffect, useRef } from 'react' +import { io, Socket } from 'socket.io-client' +import { useAppStore } from '../store' -### src/socket/messaging.ts +const SOCKET_URL = process.env.EXPO_PUBLIC_API_URL ?? '' -```typescript -import { Server } from 'socket.io' -import { AuthSocket } from '../middleware/socketAuth' -import { prisma } from '../lib/prisma' -import { notify } from '../services/push.service' +export function useSocket(): Socket | null { + const socketRef = useRef(null) + const { token } = useAppStore() -interface MessagePayload { - matchId: string; content: string; type: 'text' | 'media' | 'voice'; mediaUrl?: string -} + useEffect(() => { + if (!token) return -export function registerMessaging(io: Server, socket: AuthSocket) { - const { userId } = socket - socket.on('chat:join', ({ matchId }: { matchId: string }) => socket.join(`match:${matchId}`)) - socket.on('message:send', async (payload: MessagePayload) => { - const { matchId, content, type, mediaUrl } = payload - const match = await prisma.match.findFirst({ - where: { id: matchId, OR: [{ userId1: userId }, { userId2: userId }], status: 'active' }, + const socket = io(SOCKET_URL, { + auth: { token }, + transports: ['websocket', 'polling'], + reconnectionAttempts: 5, + reconnectionDelay: 1000, }) - if (!match) { socket.emit('message:error', { error: 'Not authorized' }); return } - const recipientId = match.userId1 === userId ? match.userId2 : match.userId1 - const message = await prisma.message.create({ data: { matchId, senderId: userId, recipientId, content, type, mediaUrl } }) - io.to(`user:${recipientId}`).emit('message:receive', { id: message.id, matchId, senderId: userId, content, type, mediaUrl, createdAt: message.createdAt }) - socket.emit('message:delivered', { id: message.id, matchId }) - const recipientSockets = await io.in(`user:${recipientId}`).fetchSockets() - if (recipientSockets.length === 0) { - const sender = await prisma.user.findUnique({ where: { id: userId }, select: { displayName: true } }) - await notify.newMessage(recipientId, sender?.displayName ?? 'Someone', matchId) + + socket.on('connect', () => console.log('[Socket] Connected')) + socket.on('disconnect', (reason) => console.log('[Socket] Disconnected:', reason)) + socket.on('connect_error', (err) => console.error('[Socket] Error:', err.message)) + + socketRef.current = socket + + return () => { + socket.disconnect() + socketRef.current = null } - }) - socket.on('message:read', async ({ matchId }: { matchId: string }) => { - await prisma.message.updateMany({ where: { matchId, recipientId: userId, readAt: null }, data: { readAt: new Date() } }) - const match = await prisma.match.findUnique({ where: { id: matchId } }) - if (!match) return - const senderId = match.userId1 === userId ? match.userId2 : match.userId1 - io.to(`user:${senderId}`).emit('message:read', { matchId }) - }) - socket.on('typing:start', ({ matchId }: { matchId: string }) => socket.to(`match:${matchId}`).emit('typing:start')) - socket.on('typing:stop', ({ matchId }: { matchId: string }) => socket.to(`match:${matchId}`).emit('typing:stop')) + }, [token]) + + return socketRef.current } ``` ----- -## SERVICES - -### src/services/s3.service.ts +## UNIVERSAL S3 PATTERN ```typescript +// src/services/s3.service.ts — universal pattern import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { env } from '../config/env' @@ -1072,17 +741,29 @@ import { randomUUID } from 'crypto' const s3 = new S3Client({ region: env.AWS_REGION, - credentials: { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY }, + credentials: { + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, + }, }) -type MediaType = 'profile' | 'chat' | 'voice-sample' - -export async function getPresignedUploadUrl(userId: string, mediaType: MediaType, contentType: string) { +export async function getPresignedUploadUrl( + userId: string, + folder: string, // e.g. 'profile', 'media', 'audio' + contentType: string, + expiresIn = 300 // 5 min default +) { const ext = contentType.split('/')[1] ?? 'bin' - const objectKey = `${mediaType}/${userId}/${randomUUID()}.${ext}` - const command = new PutObjectCommand({ Bucket: env.AWS_S3_BUCKET, Key: objectKey, ContentType: contentType, Metadata: { uploadedBy: userId } }) - const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 }) - return { uploadUrl, objectKey, publicUrl: `https://${env.AWS_S3_BUCKET}.s3.${env.AWS_REGION}.amazonaws.com/${objectKey}` } + const objectKey = `${folder}/${userId}/${randomUUID()}.${ext}` + const command = new PutObjectCommand({ + Bucket: env.AWS_S3_BUCKET, + Key: objectKey, + ContentType: contentType, + Metadata: { uploadedBy: userId }, + }) + const uploadUrl = await getSignedUrl(s3, command, { expiresIn }) + const publicUrl = `https://${env.AWS_S3_BUCKET}.s3.${env.AWS_REGION}.amazonaws.com/${objectKey}` + return { uploadUrl, objectKey, publicUrl } } export async function getS3Object(objectKey: string): Promise { @@ -1100,396 +781,574 @@ export async function deleteS3Object(objectKey: string) { } ``` -### src/services/livekit.service.ts +----- + +## UNIVERSAL STRIPE WEBHOOK PATTERN ```typescript -import { AccessToken } from 'livekit-server-sdk' +// src/routes/payments.ts — universal Stripe webhook pattern +import { Router, Request, Response } from 'express' +import Stripe from 'stripe' import { env } from '../config/env' -export function generateLiveKitToken({ userId, roomName, canPublish = true, canSubscribe = true, ttlSeconds = 3600 }: { - userId: string; roomName: string; canPublish?: boolean; canSubscribe?: boolean; ttlSeconds?: number -}): string { - const token = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, { identity: userId, ttl: ttlSeconds }) - token.addGrant({ room: roomName, roomJoin: true, canPublish, canSubscribe, canPublishData: true }) - return token.toJwt() +const router = Router() +const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2024-06-20' }) + +// CRITICAL: Must be registered BEFORE express.json() in app.ts +// app.use('/payments/webhook', express.raw({ type: 'application/json' })) +router.post('/webhook', async (req: Request, res: Response) => { + const sig = req.headers['stripe-signature'] + if (!sig) return res.status(400).json({ error: 'Missing stripe-signature' }) + + let event: Stripe.Event + try { + event = stripe.webhooks.constructEvent(req.body, sig, env.STRIPE_WEBHOOK_SECRET) + } catch (err) { + console.error('[Stripe] Webhook signature failed:', (err as Error).message) + return res.status(400).json({ error: 'Invalid signature' }) + } + + try { + await handleStripeEvent(event) + res.json({ received: true }) + } catch (err) { + console.error('[Stripe] Handler failed:', err) + res.status(500).json({ error: 'Handler failed' }) + } +}) + +async function handleStripeEvent(event: Stripe.Event) { + switch (event.type) { + case 'payment_intent.succeeded': + // handle payment + break + case 'customer.subscription.deleted': + // handle cancellation + break + default: + console.log(`[Stripe] Unhandled: ${event.type}`) + } } + +export default router ``` -### src/services/push.service.ts +----- + +## UNIVERSAL PUSH NOTIFICATION PATTERN ```typescript -import Expo, { ExpoPushMessage } from 'expo-server-sdk' -import { prisma } from '../lib/prisma' +// src/hooks/usePushNotifications.ts — universal RN pattern +import { useEffect } from 'react' +import * as Notifications from 'expo-notifications' +import * as Device from 'expo-device' +import { Platform } from 'react-native' +import { api } from '../lib/api' + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: true, + }), +}) -const expo = new Expo() +export function usePushNotifications() { + useEffect(() => { register() }, []) -async function sendPushNotification({ userId, type, title, body, data = {} }: { - userId: string; type: string; title: string; body: string; data?: Record -}) { - const user = await prisma.user.findUnique({ where: { id: userId }, select: { expoPushToken: true, pushEnabled: true } }) - if (!user?.expoPushToken || !user.pushEnabled) return - if (!Expo.isExpoPushToken(user.expoPushToken)) return - const message: ExpoPushMessage = { to: user.expoPushToken, sound: 'default', title, body, data: { type, ...data }, priority: type === 'new_message' ? 'high' : 'normal' } - try { - const chunks = expo.chunkPushNotifications([message]) - for (const chunk of chunks) { - const tickets = await expo.sendPushNotificationsAsync(chunk) - for (const ticket of tickets) { - if (ticket.status === 'error' && ticket.details?.error === 'DeviceNotRegistered') { - await prisma.user.update({ where: { id: userId }, data: { expoPushToken: null } }) - } - } + const register = async () => { + if (!Device.isDevice) return + + const { status: existing } = await Notifications.getPermissionsAsync() + let finalStatus = existing + if (existing !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync() + finalStatus = status } - } catch (err) { console.error('[Push] Send failed:', err) } -} + if (finalStatus !== 'granted') return -export const notify = { - newMessage: (userId: string, senderName: string, matchId: string) => - sendPushNotification({ userId, type: 'new_message', title: senderName, body: 'Sent you a message', data: { matchId } }), - revealMatched: (userId: string, withName: string, matchId: string) => - sendPushNotification({ userId, type: 'reveal_matched', title: 'Mutual Reveal', body: `You and ${withName} revealed to each other`, data: { matchId } }), - newMatch: (userId: string, matchName: string, matchId: string) => - sendPushNotification({ userId, type: 'new_match', title: 'New Match Nearby', body: `${matchName} is close`, data: { matchId } }), -} + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'default', + importance: Notifications.AndroidImportance.MAX, + }) + } + + const { data: token } = await Notifications.getExpoPushTokenAsync({ + projectId: process.env.EXPO_PUBLIC_PROJECT_ID, + }) -export async function registerPushToken(userId: string, token: string) { - if (!Expo.isExpoPushToken(token)) throw new Error(`Invalid Expo push token: ${token}`) - await prisma.user.update({ where: { id: userId }, data: { expoPushToken: token } }) + await api.post('/push/register', { token }).catch(console.error) + } } ``` ----- -## APP ENTRY POINTS - -### src/app.ts +## UNIVERSAL LOCATION HOOK ```typescript -import express from 'express' -import helmet from 'helmet' -import cors from 'cors' -import { globalLimiter, authLimiter } from './middleware/rateLimit' -import { env } from './config/env' -import healthRouter from './routes/health' -import usersRouter from './routes/users' -import matchesRouter from './routes/matches' -import livekitRouter from './routes/livekit' -import mediaRouter from './routes/media' -import voiceRouter from './routes/voice' -import revealRouter from './routes/reveal' -import socialRouter from './routes/social' -import messagesRouter from './routes/messages' -import venuesRouter from './routes/venues' -import pushRouter from './routes/push' -import paymentsRouter from './routes/payments' +// src/hooks/useLocation.ts — universal RN pattern +import { useEffect, useRef } from 'react' +import * as Location from 'expo-location' +import { useSocket } from './useSocket' + +export function useLocation() { + const socket = useSocket() + const watchRef = useRef(null) + + const startTracking = async () => { + const { status } = await Location.requestForegroundPermissionsAsync() + if (status !== 'granted') return + + watchRef.current = await Location.watchPositionAsync( + { accuracy: Location.Accuracy.Balanced, timeInterval: 30000, distanceInterval: 50 }, + (location) => { + socket?.emit('presence:location', { + lat: location.coords.latitude, + lng: location.coords.longitude, + }) + } + ) + } -const app = express() -app.use(helmet()) -app.use(cors({ origin: env.CORS_ORIGIN, credentials: true })) -app.use('/payments/webhook', express.raw({ type: 'application/json' })) -app.use(express.json({ limit: '10kb' })) -app.use(globalLimiter) -app.use('/health', healthRouter) -app.use('/users', usersRouter) -app.use('/matches', matchesRouter) -app.use('/livekit', livekitRouter) -app.use('/media', mediaRouter) -app.use('/voice', voiceRouter) -app.use('/reveal', revealRouter) -app.use('/social', socialRouter) -app.use('/messages', messagesRouter) -app.use('/venues', venuesRouter) -app.use('/push', pushRouter) -app.use('/payments', paymentsRouter) -app.use((_req, res) => res.status(404).json({ error: 'Not found' })) -app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - console.error('[Express]', err.message) - res.status(500).json({ error: 'Internal server error' }) -}) -export default app + const stopTracking = () => { + watchRef.current?.remove() + watchRef.current = null + } + + useEffect(() => () => stopTracking(), []) + + return { startTracking, stopTracking } +} ``` -### src/index.ts +----- + +## UNIVERSAL ACTIVITY TRACKING HOOK ```typescript -import './config/env' -import http from 'http' -import app from './app' -import { createSocketServer } from './socket' -import { env } from './config/env' +// src/hooks/useActivityTracking.ts — universal RN pattern +// Powers CCS (Circadian Compatibility Score) signal accumulation +import { useEffect, useRef } from 'react' +import { AppState, AppStateStatus } from 'react-native' +import { useSocket } from './useSocket' + +export function useActivityTracking() { + const socket = useSocket() + const appState = useRef(AppState.currentState) + + useEffect(() => { + const sub = AppState.addEventListener('change', (nextState) => { + if (appState.current === 'background' && nextState === 'active') { + socket?.emit('app:foreground') + } else if (nextState === 'background') { + socket?.emit('app:background') + } + appState.current = nextState + }) + return () => sub.remove() + }, [socket]) +} +``` -const httpServer = http.createServer(app) -const io = createSocketServer(httpServer) -app.set('io', io) +----- -httpServer.listen(env.PORT, () => { - console.log(`ANL API [${env.NODE_ENV}] — port ${env.PORT}`) -}) +## UNIVERSAL ROOT LAYOUT PATTERN (Expo) -const shutdown = async (signal: string) => { - console.log(`${signal} — shutting down`) - io.close() - httpServer.close(() => process.exit(0)) - setTimeout(() => process.exit(1), 10000) +```typescript +// app/_layout.tsx — universal Expo root layout +import { ErrorBoundary } from '../src/components/ErrorBoundary' +import { useActivityTracking } from '../src/hooks/useActivityTracking' +import { usePushNotifications } from '../src/hooks/usePushNotifications' + +export default function RootLayout() { + // Wire universal hooks + useActivityTracking() // CCS signal accumulation + usePushNotifications() // push token registration + + return ( + + {/* repo-specific navigator */} + + ) +} +``` + +----- + +## UNIVERSAL TSCONFIG + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "commonjs", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] } +``` -process.on('SIGTERM', () => shutdown('SIGTERM')) -process.on('SIGINT', () => shutdown('SIGINT')) +----- + +## UNIVERSAL PACKAGE.JSON SCRIPTS + +```json +{ + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc --project tsconfig.build.json", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext .ts,.tsx --fix", + "test": "vitest run", + "test:watch": "vitest", + "db:migrate": "prisma migrate deploy", + "db:generate": "prisma generate", + "db:studio": "prisma studio", + "db:push": "prisma db push" + } +} ``` ----- -## NIGHTLY JOB +## UNIVERSAL .GITIGNORE -### src/jobs/nightly.ts +``` +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build +dist/ +build/ +.expo/ +.next/ + +# Environment +.env +.env.local +.env.production +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Testing +coverage/ +.nyc_output/ + +# Prisma +prisma/*.db +prisma/*.db-journal + +# Expo +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ +``` -```typescript -import '../config/env' -import { prisma } from '../lib/prisma' -import { recomputeProfile } from '../services/circadian.service' -import { expireStaleCommitments } from '../services/reveal.service' +----- -async function run() { - const start = Date.now() - console.log('[NIGHTLY] Started:', new Date().toISOString()) - const errors: string[] = [] +## UNIVERSAL SECURITY CHECKLIST - // Recompute stale circadian profiles - const stale = await prisma.circadianProfile.findMany({ - where: { lastComputedAt: { lt: new Date(Date.now() - 6 * 3600000) } }, - select: { userId: true }, - }) - for (const { userId } of stale) { - try { await recomputeProfile(userId) } - catch (e) { errors.push(`circadian:${userId}: ${(e as Error).message}`) } - } +Applied to every new repo and every PR: - // Expire reveal commitments - const expired = await expireStaleCommitments().catch(e => { errors.push(`reveal: ${e.message}`); return 0 }) +``` +AUTH +[ ] All routes behind requireAuth middleware +[ ] Socket.io behind socketAuthMiddleware +[ ] Auth errors return 401 with no detail leakage +[ ] Token expiry handled gracefully on client + +INPUT VALIDATION +[ ] Zod schema on every request body +[ ] Type coercion for numeric query params +[ ] Array length limits on all bulk operations +[ ] File type + size validation on all uploads + +SECRETS +[ ] All secrets in env vars — never in source +[ ] .env in .gitignore — committed only .env.example +[ ] Secrets set via fly secrets — never in fly.toml +[ ] No secrets in logs or error messages + +RATE LIMITING +[ ] globalLimiter on all routes +[ ] authLimiter on auth routes +[ ] Custom limiters on expensive operations + +HEADERS +[ ] helmet() applied in app.ts +[ ] CORS origin locked to known domains +[ ] force_https = true in fly.toml + +DATABASE +[ ] No raw string interpolation in queries +[ ] Prisma parameterized queries always +[ ] PostGIS raw queries use tagged template literals +[ ] Connection pool sized to VM capacity +``` - // Cleanup old data - await prisma.$executeRaw`DELETE FROM activity_events WHERE created_at < NOW() - INTERVAL '90 days'` - await prisma.$executeRaw`DELETE FROM venue_visits WHERE departed_at IS NOT NULL AND departed_at < NOW() - INTERVAL '30 days'` - await prisma.socialExclusion.deleteMany({ where: { expiresAt: { lt: new Date() } } }) +----- - console.log('[NIGHTLY] Complete:', { circadianRecomputed: stale.length, expired, errors, durationMs: Date.now() - start }) - if (errors.length > 0) console.error('[NIGHTLY] Errors:', errors) -} +## UNIVERSAL PERFORMANCE CHECKLIST -run().then(() => process.exit(0)).catch(e => { console.error('[NIGHTLY] Fatal:', e); process.exit(1) }) +``` +DATABASE +[ ] EXPLAIN ANALYZE run on all new queries +[ ] Indexes on all foreign keys +[ ] GIST indexes on all PostGIS geometry columns +[ ] Prisma select only required fields (no select *) +[ ] Pagination on all list endpoints + +REACT NATIVE +[ ] No blocking operations on JS thread +[ ] Reanimated 3 for all animations +[ ] FlatList with getItemLayout for long lists +[ ] useMemo on expensive computations +[ ] useCallback on all event handlers passed as props +[ ] Images lazy loaded with proper cache headers + +BACKEND +[ ] Promise.all for parallel independent operations +[ ] Never await in a loop (use Promise.all) +[ ] Redis cache for expensive repeated queries +[ ] Response compression (compression middleware) +[ ] Keep-alive connections to DB pool ``` ----- -## FRONTEND TOKENS +## UNIVERSAL TELEMETRY CONTRACT -### src/theme/tokens.ts +Every non-trivial module must answer at runtime: ```typescript -export const colors = { - bg: '#0D0A14', bgElevated: '#13101C', bgCard: '#1A1625', - border: '#2A2040', borderSubtle: '#1E1830', - accent: '#8B5CF6', accentWarm: '#C9A84C', accentMuted: '#4C3580', - text: '#F0EBF8', textMuted: '#7B6B9A', textDim: '#4A3D66', - danger: '#EF4444', success: '#10B981', reveal: '#C9A84C', -} as const +// Telemetry interface — every service implements this +interface TelemetrySignals { + // HEALTH — Am I alive? + health(): Promise<{ status: 'ok' | 'degraded' | 'dead'; latency_ms: number }> -export const fonts = { - display: 'Cinzel-Regular', displayB: 'Cinzel-Bold', - ui: 'DMMono-Regular', uiM: 'DMMono-Medium', -} as const + // PRESSURE — How hard am I working? + pressure(): { requestsPerMin: number; avgLatency_ms: number; queueDepth: number } -export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48 } as const -export const radius = { sm: 4, md: 8, lg: 16, full: 9999 } as const + // EFFICIENCY — What am I leaking? + efficiency(): { memoryMB: number; openHandles: number; cacheHitRate: number } + + // FAILURE — What went wrong? + // Emit structured errors: { code, message, context, timestamp, traceId } + + // TRACE — Where am I in execution? + // Propagate traceId through all async boundaries +} ``` ----- -## INFRASTRUCTURE +## REPO-SPECIFIC OVERRIDES -### fly.toml +### ANL — AllNightLong -```toml -app = "anl-api" -primary_region = "ewr" +``` +Auth: Supabase (anonymous-first) +DB: PostgreSQL + PostGIS (spatial queries mandatory) +Deploy: Fly.io ewr region +Accent: Gold (#C9A84C) primary, Violet (#8B5CF6) secondary +Special: 5 patent features — VCMS+ pipeline — do not modify weights without instruction +Patents: CCS + VAS + VCS + Cryptographic Mutual Reveal + Social Graph Exclusion +VCMS+: Proximity(0.35) + CCS(0.25) + VAS(0.25) + VCS(0.15) +``` -[build] - dockerfile = "Dockerfile" +### game-on -[env] - PORT = "3000" - NODE_ENV = "production" +``` +Auth: JWT (custom) +DB: PostgreSQL + Prisma +Deploy: Fly.io +Special: Real-money gaming — KYC required — Stripe escrow wallet +Games: 8 games — tournament system — 3-tier paywall (Free/Plus/Premium) +Accent: Gold (#C9A84C) +``` -[http_service] - internal_port = 3000 - force_https = true - auto_stop_machines = false - auto_start_machines = true - min_machines_running = 1 +### VAULT - [http_service.concurrency] - type = "connections" - hard_limit = 500 - soft_limit = 200 +``` +Auth: JWT + WireGuard keypair +DB: PostgreSQL + Prisma +Deploy: Fly.io +Special: WireGuard peer management — kill switch — libsodium keypair +Accent: Gold (#C9A84C) +``` -[[vm]] - memory = "512mb" - cpu_kind = "shared" - cpus = 1 +### luminary -[checks] - [checks.health] - grace_period = "10s" - interval = "30s" - method = "GET" - path = "/health" - port = 3000 - timeout = "5s" - type = "http" +``` +Auth: Supabase +Deploy: Vercel (AI Gateway) +Special: Gemini API — batch generation — no traditional DB needed +Accent: Violet (#8B5CF6) ``` -### Dockerfile - -```dockerfile -FROM node:20-alpine AS builder -WORKDIR /app -RUN apk add --no-cache openssl -COPY package*.json ./ -RUN npm ci --ignore-scripts -COPY . . -RUN npx prisma generate -RUN npm run build +### FORGE -FROM node:20-alpine AS runner -WORKDIR /app -RUN apk add --no-cache openssl -RUN addgroup -S anl && adduser -S anl -G anl -COPY --from=builder --chown=anl:anl /app/dist ./dist -COPY --from=builder --chown=anl:anl /app/node_modules ./node_modules -COPY --from=builder --chown=anl:anl /app/prisma ./prisma -COPY --from=builder --chown=anl:anl /app/package.json ./ -USER anl -EXPOSE 3000 -CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] +``` +Auth: Supabase +Deploy: Vercel +Special: Groq API (Llama 3.3 70B) — terminal aesthetic — system prompt tuned for cloudygetty-ai stack +Accent: Gold (#C9A84C) ``` -### .github/workflows/fly-deploy.yml +### don't-reneg-on-me -```yaml -name: ANL Deploy -on: - push: - branches: [main] -jobs: - deploy: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm run typecheck - - run: npm run lint - - uses: superfly/flyctl-actions/setup-flyctl@master - - run: flyctl deploy --remote-only - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} +``` +Auth: Supabase +Deploy: Vercel +Special: Card game rules engine — felt/casino aesthetic — Cinzel Decorative font +Accent: Crimson (#9B2335) on felt green ``` ------ - -## VCMS+ SCORING REFERENCE +### precrime ``` -VCMS+ = Proximity(0.35) + CCS(0.25) + VAS(0.25) + VCS(0.15) - -CCS = cosine_similarity(activityVector_A, activityVector_B) × confidence_weight - cold start (confidence < 0.1) → 0.5 neutral +Lang: Go (orchestration) + Rust (hot-path) + TypeScript (client) +Deploy: Fly.io +Special: Static analysis engine — Contract Graph — Suspicion Engine — Reality Check +Market: FinTech primary — Audit/Gate/Org tiers +No RN, no Prisma, no Supabase +``` -VAS = cosine_similarity(venueAffinityVector_A, venueAffinityVector_B) - no shared venues → 0.5 neutral +### echo -VCS = mfcc_similarity(0.50) + pitch_overlap(0.25) + speech_rate(0.15) + energy(0.10) - × confidence_weight - no consent or sample → 0.5 neutral +``` +Lang: TypeScript (compiler) — output configurable +Deploy: npm package +Special: Vite plugin — esbuild backend — JSX-like syntax — $ reactive prefix +No backend, no DB, no auth +``` -Social Graph Exclusion: - excluded = explicit_blocks ∪ contact_matches ∪ mutual_connections - applied BEFORE proximity query — silent, bidirectional +### crowned-lion -Cryptographic Mutual Reveal: - commitment = HMAC-SHA256(userId:targetId:nonce, REVEAL_HMAC_SECRET) - reveal_key = HMAC-SHA256(sorted(userA, userB), REVEAL_HMAC_SECRET) - payload = AES-256-GCM(target_profile, reveal_key) - match = both commitments verified + TTL not expired +``` +Lang: Plain JS only — no TypeScript — no framework +Deploy: Vercel (static) +Special: HTML5 Canvas — PWA — NJ-compliant social casino +No React, no Node, no Prisma ``` ----- -## PATENT CLAIMS REFERENCE - +## DEPLOYMENT COMMANDS REFERENCE + +```bash +# Fly.io — new repo +fly launch --no-deploy --name REPO_NAME --region ewr +fly secrets set KEY=value KEY2=value2 +fly deploy + +# Fly.io — existing repo +fly deploy +fly logs +fly status +curl https://REPO_NAME.fly.dev/health + +# Vercel — new repo +vercel --prod + +# Database +npx prisma migrate dev --name migration_name # development +npx prisma migrate deploy # production (runs in Dockerfile CMD) +npx prisma generate # after schema changes +npx prisma studio # GUI + +# GitHub push pattern (every session must end with this) +git add . +git commit -m "feat(scope): description" +git push origin main ``` -Patent 1 — CCS (Circadian Compatibility Score) - Method: Computing chronobiological compatibility from in-app behavioral - activity vectors using cosine similarity on 24-element temporal arrays - with confidence-weighted cold-start neutral default. -Patent 2 — VAS (Venue Affinity Score) - Method: Matching via co-presence probability at real-world venues - using dwell-confirmed visit history with exponential decay weighting. - -Patent 3 — VCS (Voice Chemistry Score) - Method: Acoustic feature extraction (MFCC, pitch, speech rate, energy) - with cross-user spectral compatibility scoring as a matching signal. +----- -Patent 4 — Cryptographic Mutual Reveal - Method: Commitment scheme using HMAC-SHA256 with TTL-bounded mutual - verification before identity disclosure via AES-256-GCM. +## COMMIT MESSAGE FORMAT -Patent 5 — Social Graph Exclusion Engine - Method: Silent exclusion filter using hashed contact matching, - mutual connection graph traversal, and configurable explicit blocks. +All repos use conventional commits: -Filing targets: USPTO Class 9 (software) + Class 45 (dating services) -Priority date: file provisional immediately — all 5 features complete +``` +feat(scope): add new feature +fix(scope): fix bug +chore(scope): maintenance, deps, config +refactor(scope): code improvement without behavior change +perf(scope): performance improvement +test(scope): add or update tests +docs(scope): documentation update +security(scope): security fix + +scope = repo area: auth, api, ui, db, infra, socket, patent, etc. + +Examples: + feat(anl/ccs): add confidence decay for stale profiles + fix(game-on/wallet): prevent double-charge on network retry + chore(vault/deps): upgrade livekit-server-sdk to 2.0 + security(anl/auth): add rate limit to reveal endpoint ``` ----- -## DEPLOYMENT SEQUENCE - -``` -1. fly launch --no-deploy --name anl-api --region ewr -2. fly secrets set \ - DATABASE_URL="..." \ - DIRECT_URL="..." \ - SUPABASE_URL="..." \ - SUPABASE_ANON_KEY="..." \ - SUPABASE_SERVICE_ROLE_KEY="..." \ - SUPABASE_JWT_SECRET="..." \ - LIVEKIT_API_KEY="..." \ - LIVEKIT_API_SECRET="..." \ - LIVEKIT_WS_URL="..." \ - AWS_ACCESS_KEY_ID="..." \ - AWS_SECRET_ACCESS_KEY="..." \ - AWS_REGION="us-east-1" \ - AWS_S3_BUCKET="anl-media" \ - STRIPE_SECRET_KEY="..." \ - STRIPE_WEBHOOK_SECRET="..." \ - JWT_SECRET="..." \ - REVEAL_HMAC_SECRET="..." \ - CORS_ORIGIN="..." -3. fly deploy -4. curl https://anl-api.fly.dev/health -5. npx expo build --platform ios -6. Submit to TestFlight -7. File provisional patent — USPTO EFS-Web +## SESSION END CHECKLIST + +Every Claude Code session ends with: + +``` +[ ] All new files typechecked (npx tsc --noEmit) +[ ] All new routes tested (curl or Supertest) +[ ] No console.log left in production paths +[ ] Error boundaries on all new RN screens +[ ] Telemetry wired on all new services +[ ] .env.example updated if new vars added +[ ] PROJECT.md updated with new file entries +[ ] TODO.md updated — completed tasks checked, new tasks added +[ ] git add . && git commit -m "..." && git push origin main ``` ----- -*ANL SPECS v1.0 — Sentinel Engine v6.0 ENTROPY-ZERO* -*All implementations verified. All patents documented. Deploy when ready.* \ No newline at end of file +*CLOUDYGETTY-AI UNIVERSAL SPECS v1.0* +*Sentinel Engine v6.0 · ENTROPY-ZERO* +*Applies org-wide. Every repo. Every session.* +*Last updated: 2026-05-16* From 80615aa8f05e86521033086ffee2a9fef47e58f4 Mon Sep 17 00:00:00 2001 From: Cloudygetty Date: Sat, 16 May 2026 01:14:03 -0400 Subject: [PATCH 2/2] docs: restore ANL repo-specific specs.md --- specs.md | 2227 +++++++++++++++++++++++++++++------------------------- 1 file changed, 1184 insertions(+), 1043 deletions(-) diff --git a/specs.md b/specs.md index 344572a..543b3ef 100644 --- a/specs.md +++ b/specs.md @@ -1,306 +1,425 @@ -# CLOUDYGETTY-AI — Universal GOD-MODE Specifications +# ANL — Full Implementation Specifications # Sentinel Engine v6.0 · ENTROPY-ZERO -# Applies to every repo in the cloudygetty-ai organization. +# This document is the authoritative source for all ANL implementations. -# This is the single source of truth for all implementations, patterns, and contracts. +# Claude Code reads this file to reconstruct the full codebase. -# Claude Code reads this file on every session across every repo. +# Do not modify without updating version + changelog. ----- ## HOW TO USE THIS FILE -This file lives at the org level and is referenced by every repo's CLAUDE.md. -It defines the universal implementation patterns, stack contracts, and code -standards that apply across all cloudygetty-ai projects. - -Per-repo SPECS.md files extend this document with repo-specific implementations. +This is a GOD-MODE spec file. Every implementation in the ANL codebase +is defined here with full code, schemas, contracts, and architecture decisions. Claude Code workflow: -1. Read CLAUDE.md — load operational persona + repo constraints -1. Read this file — load universal patterns + org standards -1. Read PROJECT.md — load repo-specific state + file map -1. Read repo SPECS.md (if exists) — load repo-specific implementations -1. Scaffold, implement, verify, commit, push +1. Read CLAUDE.md — load operational persona + constraints +1. Read PROJECT.md — load file map + known issues +1. Read SPECS.md (this file) — load all implementations +1. Scaffold each file exactly as specified +1. Run npx tsc –noEmit after every file +1. Run git add . && git commit && git push after full scaffold ----- -## ORG REGISTRY +## ENVIRONMENT VALIDATION -``` -cloudygetty-ai — active repositories: - -ACTIVE — in production or active development -├── ANL AllNightLong dating app -│ RN/Expo + Express + PostGIS + Socket.io + LiveKit + Stripe -│ 5 patent-candidate features · Fly.io deploy -│ -├── game-on Real-money skill gaming platform -│ RN/Expo + Express monorepo · 8 games · Socket.io rooms -│ Stripe wallet · KYC · Tournament system · Fly.io -│ -├── VAULT WireGuard VPN app -│ RN client + Web dashboard + bash server bootstrap -│ Stripe billing · keypair registration · kill switch -│ -├── luminary AI image generation playground -│ Gemini via Vercel AI Gateway · batch gen · style presets -│ LLM prompt enhancement · A/B comparison · inpainting -│ -├── FORGE Groq-backed LLM chat UI -│ Llama 3.3 70B · dark luxury terminal aesthetic -│ Obsidian/gold · Cinzel · DM Mono · system prompt tuned -│ -├── don't-reneg-on-me Spades card game (formerly SpadesRoyale) -│ Vite/React + RN · dark luxury casino UI -│ Cinzel Decorative · obsidian/felt palette · gold shimmer -│ -├── open-up-app Social app -│ React/Node/Prisma/JWT · Next.js · Vercel -│ -├── crowned-lion PWA social casino -│ Plain JS · HTML5 Canvas · NJ-compliant · no framework -│ -├── precrime Static analysis + correctness engine -│ Go orchestration · Rust hot-path · TS/Go/Python targets -│ Contract Graph · Suspicion Engine · Reality Check layer -│ FinTech primary market · Audit/Gate/Org tiers -│ -├── echo Programming language + Vite plugin -│ esbuild compiler · JSX-like syntax · $ reactive state -│ Outputs: React Native components or plain JS/TS -│ -├── CLAUDE Autonomous dev environment -│ RN/TS · Zustand · Entropy-Zero V3.0 -│ Self-healing protocols · strict modularity -│ -├── vaultify Auth-as-a-service -│ JWT/OAuth/Redis/Prisma · proprietary platform -│ -├── PROXM Location-based discovery -│ PostGIS · view-once · 2FA -│ -├── hole-eaters Location social -│ WebRTC · Claude API integration -│ -└── zero-to-one Startup SPA - ConvertKit · Lemon Squeezy -``` +### src/config/env.ts ------ +```typescript +import { z } from 'zod' -## UNIVERSAL STACK +const EnvSchema = z.object({ + DATABASE_URL: z.string().url(), + DIRECT_URL: z.string().url(), + SUPABASE_URL: z.string().url(), + SUPABASE_ANON_KEY: z.string().min(1), + SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), + SUPABASE_JWT_SECRET: z.string().min(1), + LIVEKIT_API_KEY: z.string().min(1), + LIVEKIT_API_SECRET: z.string().min(1), + LIVEKIT_WS_URL: z.string().url(), + AWS_ACCESS_KEY_ID: z.string().min(1), + AWS_SECRET_ACCESS_KEY: z.string().min(1), + AWS_REGION: z.string().min(1), + AWS_S3_BUCKET: z.string().min(1), + STRIPE_SECRET_KEY: z.string().min(1), + STRIPE_WEBHOOK_SECRET: z.string().min(1), + REVEAL_HMAC_SECRET: z.string().min(32), + PORT: z.coerce.number().default(3000), + NODE_ENV: z.enum(['development', 'production', 'test']), + JWT_SECRET: z.string().min(32), + CORS_ORIGIN: z.string().url(), +}) -### Languages +const parsed = EnvSchema.safeParse(process.env) -``` -TypeScript 5.x strict mode — all repos except: - crowned-lion → plain JS only - precrime → Go (orchestration) + Rust (hot-path) + TS (client) - echo → TypeScript (compiler output configurable) +if (!parsed.success) { + console.error('ENV VALIDATION FAILED — API will not start') + console.error(parsed.error.format()) + process.exit(1) +} + +export const env = parsed.data ``` -### Frontend +----- -``` -Mobile: React Native + Expo SDK 51+ -Web: React 18 + Vite or Next.js 14+ -State: Zustand -Data: TanStack Query v5 -Validation: Zod -Animation: Reanimated 3 (RN) / Framer Motion (web) -``` +## DATABASE -### Backend +### src/lib/prisma.ts -``` -Runtime: Node.js 20 LTS -Framework: Express (primary) / Fastify (performance-critical) -ORM: Prisma 5+ -DB: PostgreSQL 15 + PostGIS 3.3 -Cache: Redis 7 -Auth: Supabase Auth (primary) / Vaultify (internal) -Realtime: Socket.io 4 (sticky sessions required on Fly) -Storage: AWS S3 (presigned URLs — never serve directly) -Payments: Stripe -Video: LiveKit WebRTC -Push: Expo Notifications (mobile) -``` +```typescript +import { PrismaClient } from '@prisma/client' +import { env } from '../config/env' -### Infrastructure +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } -``` -API deploy: Fly.io (primary) — region ewr (Newark) -Web deploy: Vercel -DB: Supabase (PostgreSQL + PostGIS) -CI/CD: GitHub Actions -Monitoring: Sentry (errors) + PostHog (product analytics) -Container: Docker multi-stage non-root +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }) + +if (env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma ``` -### Testing +### src/lib/supabase.ts -``` -Unit: Vitest (preferred) / Jest -E2E: Playwright (web) / Detox (RN) -API: Supertest +```typescript +import { createClient } from '@supabase/supabase-js' +import { env } from '../config/env' + +export const supabaseAdmin = createClient( + env.SUPABASE_URL, + env.SUPABASE_SERVICE_ROLE_KEY +) ``` ----- -## UNIVERSAL AESTHETIC SYSTEM +## PRISMA SCHEMA -Applies to ALL UI output across ALL repos. Non-negotiable. +### prisma/schema.prisma -```typescript -// Universal design tokens — extend per repo, never override core values +```prisma +generator client { + provider = "prisma-client-js" +} -export const colors = { - // Core — obsidian base - bg: '#0D0A14', // primary background - bgElevated: '#13101C', // elevated surface - bgCard: '#1A1625', // card/panel surface - border: '#2A2040', // primary border - borderSubtle: '#1E1830', // subtle separator - - // Accents — gold or violet (repo-specific) - gold: '#C9A84C', // ANL, FORGE, VAULT, game-on - violet: '#8B5CF6', // ANL alternate, CLAUDE - crimson: '#9B2335', // don't-reneg-on-me (felt/casino) - emerald: '#10B981', // success states universal - - // Text - text: '#F0EBF8', // primary text - textMuted: '#7B6B9A', // secondary text - textDim: '#4A3D66', // disabled/placeholder - - // Semantic - danger: '#EF4444', - warning: '#F59E0B', - success: '#10B981', - info: '#3B82F6', -} as const +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") +} -export const fonts = { - display: 'Cinzel-Regular', // headings, wordmarks - displayB: 'Cinzel-Bold', // hero text, scores - ui: 'DMMono-Regular', // body, labels, data - uiM: 'DMMono-Medium', // buttons, badges - // don't-reneg-on-me override: - casino: 'Cinzel Decorative', // card game titles -} as const +model User { + id String @id @default(uuid()) + displayName String? + fullName String? + bio String? + gender String? + preference String[] + photos String[] + vibeTags String[] + phone String? + isVisible Boolean @default(true) + isOnline Boolean @default(false) + lastSeen DateTime @default(now()) + pushEnabled Boolean @default(true) + expoPushToken String? + stripeCustomerId String? + subscriptionStatus String? + createdAt DateTime @default(now()) + + activityEvents ActivityEvent[] + circadianProfile CircadianProfile? + venueVisits VenueVisit[] + venueAffinities VenueAffinityProfile[] + voiceProfile VoiceProfile? + revealsSent RevealCommitment[] @relation("RevealsSent") + revealsReceived RevealCommitment[] @relation("RevealsReceived") + contactHashes ContactHash[] + exclusionsSent SocialExclusion[] @relation("ExclusionsSent") + exclusionsReceived SocialExclusion[] @relation("ExclusionsReceived") + matchesAs1 Match[] @relation("MatchUser1") + matchesAs2 Match[] @relation("MatchUser2") + messagesSent Message[] @relation("MessageSender") + messagesReceived Message[] @relation("MessageRecipient") + payments Payment[] + + @@map("users") +} -export const spacing = { - xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, -} as const +model Match { + id String @id @default(uuid()) + userId1 String + userId2 String + status String @default("active") + createdAt DateTime @default(now()) -export const radius = { - sm: 4, md: 8, lg: 16, full: 9999, -} as const + user1 User @relation("MatchUser1", fields: [userId1], references: [id], onDelete: Cascade) + user2 User @relation("MatchUser2", fields: [userId2], references: [id], onDelete: Cascade) + messages Message[] -export const shadows = { - card: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 4 }, - glow: (color: string) => ({ shadowColor: color, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.6, shadowRadius: 12, elevation: 8 }), -} as const + @@map("matches") +} + +model Message { + id String @id @default(uuid()) + matchId String + senderId String + recipientId String + content String + type String @default("text") + mediaUrl String? + readAt DateTime? + createdAt DateTime @default(now()) + + match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) + sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade) + recipient User @relation("MessageRecipient", fields: [recipientId], references: [id], onDelete: Cascade) + + @@map("messages") +} + +model Payment { + id String @id @default(uuid()) + userId String + stripePaymentIntentId String @unique + status String + amount Int + currency String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("payments") +} + +model ActivityEvent { + id String @id @default(uuid()) + userId String + eventType String + hourOfDay Int + dayOfWeek Int + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt(sort: Desc)]) + @@index([userId, hourOfDay]) + @@map("activity_events") +} + +model CircadianProfile { + userId String @id + activityVector Float[] + peakHours Int[] + confidence Float @default(0) + eventCount Int @default(0) + lastComputedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("circadian_profiles") +} + +model Venue { + id String @id @default(uuid()) + name String + category String + radiusM Int @default(100) + address String? + city String? + createdAt DateTime @default(now()) + + visits VenueVisit[] + affinities VenueAffinityProfile[] + + @@map("venues") +} + +model VenueVisit { + id String @id @default(uuid()) + userId String + venueId String + arrivedAt DateTime @default(now()) + departedAt DateTime? + hourOfDay Int + dayOfWeek Int + confirmed Boolean @default(false) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + + @@index([userId, arrivedAt(sort: Desc)]) + @@index([venueId, arrivedAt(sort: Desc)]) + @@map("venue_visits") +} + +model VenueAffinityProfile { + userId String + venueId String + visitCount Int @default(0) + weightedScore Float @default(0) + peakHours Int[] + lastVisitedAt DateTime? + lastComputedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + + @@id([userId, venueId]) + @@index([userId]) + @@map("venue_affinity_profiles") +} + +model VoiceProfile { + userId String @id + mfccVector Float[] + pitchMean Float? + pitchRange Float? + pitchVariance Float? + speechRate Float? + energyMean Float? + energyVariance Float? + sampleS3Key String? + sampleDurationS Float? + confidence Float @default(0) + consentGranted Boolean @default(false) + consentAt DateTime? + recordedAt DateTime? + lastComputedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("voice_profiles") +} + +model RevealCommitment { + id String @id @default(uuid()) + userId String + targetId String + commitment String + status String @default("pending") + expiresAt DateTime + createdAt DateTime @default(now()) + revealedAt DateTime? + + user User @relation("RevealsSent", fields: [userId], references: [id], onDelete: Cascade) + target User @relation("RevealsReceived", fields: [targetId], references: [id], onDelete: Cascade) + + @@unique([userId, targetId]) + @@index([targetId, status]) + @@map("reveal_commitments") +} + +model RevealAuditLog { + id String @id @default(uuid()) + eventType String + userId String + targetId String + createdAt DateTime @default(now()) + + @@map("reveal_audit_log") +} + +model ContactHash { + id String @id @default(uuid()) + userId String + hash String + uploadedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, hash]) + @@index([hash]) + @@map("contact_hashes") +} + +model SocialExclusion { + id String @id @default(uuid()) + userId String + excludedId String + reason String + expiresAt DateTime? + createdAt DateTime @default(now()) + + user User @relation("ExclusionsSent", fields: [userId], references: [id], onDelete: Cascade) + excluded User @relation("ExclusionsReceived", fields: [excludedId], references: [id], onDelete: Cascade) + + @@unique([userId, excludedId]) + @@index([userId]) + @@map("social_exclusions") +} ``` ----- -## UNIVERSAL AUTH PATTERN - -All repos use this pattern. Stack may vary (Supabase / Vaultify / JWT) but contracts are identical. +## MIDDLEWARE -### REST Auth Middleware +### src/middleware/auth.ts ```typescript -// src/middleware/auth.ts — universal pattern import { Request, Response, NextFunction } from 'express' +import { supabaseAdmin } from '../lib/supabase' export interface AuthRequest extends Request { userId: string userEmail: string } -// Implementation varies by repo auth provider: -// Supabase: supabaseAdmin.auth.getUser(token) -// Vaultify: vaultify.verify(token) -// JWT: jwt.verify(token, env.JWT_SECRET) -export async function requireAuth( - req: Request, res: Response, next: NextFunction -) { +export async function requireAuth(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers.authorization if (!authHeader?.startsWith('Bearer ')) { return res.status(401).json({ error: 'Missing authorization header' }) } const token = authHeader.slice(7) - // verify token with repo auth provider - // attach userId and userEmail to request - // call next() or return 401 + const { data, error } = await supabaseAdmin.auth.getUser(token) + if (error || !data.user) { + return res.status(401).json({ error: 'Invalid or expired token' }) + } + ;(req as AuthRequest).userId = data.user.id + ;(req as AuthRequest).userEmail = data.user.email ?? '' + next() } ``` -### Socket.io Auth Middleware +### src/middleware/socketAuth.ts ```typescript -// src/middleware/socketAuth.ts — universal pattern import { Socket } from 'socket.io' +import { supabaseAdmin } from '../lib/supabase' export interface AuthSocket extends Socket { userId: string } export async function socketAuthMiddleware( - socket: Socket, next: (err?: Error) => void + socket: Socket, + next: (err?: Error) => void ) { const token = socket.handshake.auth?.token as string | undefined if (!token) return next(new Error('AUTH_MISSING')) - // verify token — same provider as REST - // attach userId to socket - // call next() or next(new Error('AUTH_INVALID')) -} -``` - ------ - -## UNIVERSAL ENV VALIDATION - -All repos validate environment at boot. App never starts with missing vars. - -```typescript -// src/config/env.ts — universal pattern -import { z } from 'zod' - -// Define schema with all required vars for this repo -const EnvSchema = z.object({ - NODE_ENV: z.enum(['development', 'production', 'test']), - PORT: z.coerce.number().default(3000), - // ... repo-specific vars -}) - -const parsed = EnvSchema.safeParse(process.env) - -if (!parsed.success) { - console.error('ENV VALIDATION FAILED — server will not start') - console.error(parsed.error.format()) - process.exit(1) + const { data, error } = await supabaseAdmin.auth.getUser(token) + if (error || !data.user) return next(new Error('AUTH_INVALID')) + ;(socket as AuthSocket).userId = data.user.id + next() } - -export const env = parsed.data ``` ------ - -## UNIVERSAL RATE LIMITING +### src/middleware/rateLimit.ts ```typescript -// src/middleware/rateLimit.ts — universal pattern import rateLimit from 'express-rate-limit' export const globalLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 min + windowMs: 15 * 60 * 1000, max: 300, standardHeaders: true, legacyHeaders: false, @@ -313,427 +432,639 @@ export const authLimiter = rateLimit({ message: { error: 'Too many auth attempts' }, }) -// Add repo-specific limiters as needed -// e.g. mediaLimiter, aiLimiter, gameLimiter +export const mediaLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + message: { error: 'Media request limit exceeded' }, +}) ``` ----- -## UNIVERSAL HEALTH ENDPOINT +## PATENT FEATURE SERVICES -Every repo exposes `/health`. Required for Fly.io healthcheck. +### PATENT 1 — src/services/circadian.service.ts ```typescript -// src/routes/health.ts — universal pattern -import { Router } from 'express' import { prisma } from '../lib/prisma' -import { env } from '../config/env' -const router = Router() +const WINDOW_DAYS = 14 +const MIN_EVENTS_FOR_CONFIDENCE = 20 +const PEAK_HOUR_THRESHOLD = 0.7 +const STALE_HOURS = 6 -router.get('/health', async (_req, res) => { - const start = Date.now() - try { - await prisma.$queryRaw`SELECT 1` - res.status(200).json({ - status: 'healthy', - timestamp: new Date().toISOString(), - env: env.NODE_ENV, - services: { db: { status: 'ok', latency_ms: Date.now() - start } }, - }) - } catch (err) { - res.status(503).json({ - status: 'degraded', - timestamp: new Date().toISOString(), - services: { db: { status: 'error', error: (err as Error).message } }, - }) - } -}) - -export default router -``` +export type ActivityEventType = + | 'foreground' | 'background' | 'location' | 'message_sent' | 'profile_view' ------ - -## UNIVERSAL PRISMA PATTERN - -```typescript -// src/lib/prisma.ts — universal pattern -import { PrismaClient } from '@prisma/client' -import { env } from '../config/env' - -const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } - -export const prisma = - globalForPrisma.prisma ?? - new PrismaClient({ - log: env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], +export async function logActivityEvent(userId: string, eventType: ActivityEventType) { + const now = new Date() + await prisma.activityEvent.create({ + data: { userId, eventType, hourOfDay: now.getUTCHours(), dayOfWeek: now.getUTCDay() }, }) +} -if (env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma -``` - ------ - -## UNIVERSAL EXPRESS APP PATTERN - -```typescript -// src/app.ts — universal pattern -import express from 'express' -import helmet from 'helmet' -import cors from 'cors' -import { globalLimiter } from './middleware/rateLimit' -import { env } from './config/env' - -const app = express() +function buildActivityVector(events: { hourOfDay: number }[]): number[] { + const counts = new Array(24).fill(0) + for (const { hourOfDay } of events) counts[hourOfDay]++ + const max = Math.max(...counts) + if (max === 0) return counts + return counts.map(c => c / max) +} -app.use(helmet()) -app.use(cors({ origin: env.CORS_ORIGIN, credentials: true })) +function extractPeakHours(vector: number[]): number[] { + return vector + .map((weight, hour) => ({ hour, weight })) + .filter(({ weight }) => weight >= PEAK_HOUR_THRESHOLD) + .sort((a, b) => b.weight - a.weight) + .map(({ hour }) => hour) +} -// Stripe webhook must come BEFORE express.json() -// app.use('/payments/webhook', express.raw({ type: 'application/json' })) +function computeConfidence(eventCount: number): number { + if (eventCount === 0) return 0 + return Math.min(1.0, Math.log(eventCount + 1) / Math.log(MIN_EVENTS_FOR_CONFIDENCE + 1)) +} -app.use(express.json({ limit: '10kb' })) -app.use(globalLimiter) +export async function recomputeProfile(userId: string) { + const since = new Date(Date.now() - WINDOW_DAYS * 86400000) + const events = await prisma.activityEvent.findMany({ + where: { userId, createdAt: { gte: since } }, + select: { hourOfDay: true }, + }) + const vector = buildActivityVector(events) + const peakHours = extractPeakHours(vector) + const confidence = computeConfidence(events.length) + await prisma.circadianProfile.upsert({ + where: { userId }, + create: { userId, activityVector: vector, peakHours, confidence, eventCount: events.length }, + update: { activityVector: vector, peakHours, confidence, eventCount: events.length, lastComputedAt: new Date() }, + }) + return { activityVector: vector, peakHours, confidence } +} -// Mount routes here +export async function getOrComputeProfile(userId: string) { + const existing = await prisma.circadianProfile.findUnique({ where: { userId } }) + const staleThreshold = new Date(Date.now() - STALE_HOURS * 3600000) + if (existing && existing.lastComputedAt > staleThreshold) { + return { activityVector: existing.activityVector, peakHours: existing.peakHours, confidence: existing.confidence } + } + return recomputeProfile(userId) +} -app.use((_req, res) => res.status(404).json({ error: 'Not found' })) -app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - console.error('[Express]', err.message) - res.status(500).json({ error: 'Internal server error' }) -}) +function cosineSim(a: number[], b: number[]): number { + const dot = a.reduce((s, ai, i) => s + ai * (b[i] ?? 0), 0) + const magA = Math.sqrt(a.reduce((s, ai) => s + ai * ai, 0)) + const magB = Math.sqrt(b.reduce((s, bi) => s + bi * bi, 0)) + if (magA === 0 || magB === 0) return 0 + return dot / (magA * magB) +} -export default app +export async function computeCCS(userAId: string, userBId: string): Promise { + const [pA, pB] = await Promise.all([getOrComputeProfile(userAId), getOrComputeProfile(userBId)]) + if (pA.confidence < 0.1 || pB.confidence < 0.1) return 0.5 + const raw = cosineSim(pA.activityVector, pB.activityVector) + const normalized = (raw + 1) / 2 + const weight = (pA.confidence + pB.confidence) / 2 + return 0.5 + (normalized - 0.5) * weight +} ``` ------ - -## UNIVERSAL SERVER ENTRY POINT +### PATENT 2 — src/services/venue.service.ts ```typescript -// src/index.ts — universal pattern -import './config/env' -import http from 'http' -import app from './app' -import { env } from './config/env' - -const httpServer = http.createServer(app) - -// Optional: attach Socket.io -// import { createSocketServer } from './socket' -// const io = createSocketServer(httpServer) -// app.set('io', io) - -httpServer.listen(env.PORT, () => { - console.log(`[${process.env.npm_package_name}] [${env.NODE_ENV}] port ${env.PORT}`) -}) +import { prisma } from '../lib/prisma' -const shutdown = (signal: string) => { - console.log(`${signal} — graceful shutdown`) - httpServer.close(() => process.exit(0)) - setTimeout(() => process.exit(1), 10000) +const DWELL_CONFIRM_MIN = 5 +const AFFINITY_DECAY_HALF_LIFE = 30 +const WINDOW_DAYS = 90 + +export async function detectVenue(userId: string, lat: number, lng: number): Promise { + const results = await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM venues + WHERE ST_DWithin( + location::geography, + ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, + LEAST(radius_m, 200) + ) + ORDER BY ST_Distance(location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) ASC + LIMIT 1 + ` + return results[0]?.id ?? null } -process.on('SIGTERM', () => shutdown('SIGTERM')) -process.on('SIGINT', () => shutdown('SIGINT')) -``` - ------ +export async function processLocationForVenue(userId: string, lat: number, lng: number) { + const now = new Date() + const venueId = await detectVenue(userId, lat, lng) + const openVisit = await prisma.venueVisit.findFirst({ + where: { userId, departedAt: null }, + orderBy: { arrivedAt: 'desc' }, + }) + if (venueId) { + if (openVisit?.venueId === venueId) { + const dwellMin = (now.getTime() - openVisit.arrivedAt.getTime()) / 60000 + if (!openVisit.confirmed && dwellMin >= DWELL_CONFIRM_MIN) { + await prisma.venueVisit.update({ where: { id: openVisit.id }, data: { confirmed: true } }) + await updateVenueAffinity(userId, venueId, openVisit.hourOfDay) + } + } else { + if (openVisit) await prisma.venueVisit.update({ where: { id: openVisit.id }, data: { departedAt: now } }) + await prisma.venueVisit.create({ + data: { userId, venueId, arrivedAt: now, hourOfDay: now.getUTCHours(), dayOfWeek: now.getUTCDay() }, + }) + } + } else if (openVisit) { + await prisma.venueVisit.update({ where: { id: openVisit.id }, data: { departedAt: now } }) + } +} -## UNIVERSAL DOCKERFILE +async function updateVenueAffinity(userId: string, venueId: string, hourOfDay: number) { + const lambda = Math.LN2 / AFFINITY_DECAY_HALF_LIFE + const now = new Date() + const since = new Date(Date.now() - WINDOW_DAYS * 86400000) + const visits = await prisma.venueVisit.findMany({ + where: { userId, venueId, confirmed: true, arrivedAt: { gte: since } }, + select: { arrivedAt: true, hourOfDay: true }, + }) + if (visits.length === 0) return + const weightedScore = visits.reduce((sum, v) => { + const ageDays = (now.getTime() - v.arrivedAt.getTime()) / 86400000 + return sum + Math.exp(-lambda * ageDays) + }, 0) + const hourCounts = new Array(24).fill(0) + visits.forEach(v => hourCounts[v.hourOfDay]++) + const maxCount = Math.max(...hourCounts) + const peakHours = hourCounts.map((c, h) => ({ h, c })).filter(({ c }) => maxCount > 0 && c / maxCount >= 0.6).map(({ h }) => h) + await prisma.venueAffinityProfile.upsert({ + where: { userId_venueId: { userId, venueId } }, + create: { userId, venueId, visitCount: visits.length, weightedScore, peakHours, lastVisitedAt: now }, + update: { visitCount: visits.length, weightedScore, peakHours, lastVisitedAt: now, lastComputedAt: now }, + }) +} -```dockerfile -# Multi-stage · non-root · Alpine · OpenSSL for Prisma -FROM node:20-alpine AS builder -WORKDIR /app -RUN apk add --no-cache openssl -COPY package*.json ./ -RUN npm ci --ignore-scripts -COPY . . -RUN npx prisma generate -RUN npm run build +export async function computeVAS(userAId: string, userBId: string): Promise { + const [pA, pB] = await Promise.all([ + prisma.venueAffinityProfile.findMany({ where: { userId: userAId } }), + prisma.venueAffinityProfile.findMany({ where: { userId: userBId } }), + ]) + if (pA.length === 0 || pB.length === 0) return 0.5 + const mapA = new Map(pA.map(p => [p.venueId, p.weightedScore])) + const mapB = new Map(pB.map(p => [p.venueId, p.weightedScore])) + const shared = [...mapA.keys()].filter(id => mapB.has(id)) + if (shared.length === 0) return 0.5 + const dot = shared.reduce((s, id) => s + (mapA.get(id) ?? 0) * (mapB.get(id) ?? 0), 0) + const magA = Math.sqrt([...mapA.values()].reduce((s, v) => s + v * v, 0)) + const magB = Math.sqrt([...mapB.values()].reduce((s, v) => s + v * v, 0)) + if (magA === 0 || magB === 0) return 0.5 + return Math.min(1.0, dot / (magA * magB)) +} -FROM node:20-alpine AS runner -WORKDIR /app -RUN apk add --no-cache openssl -RUN addgroup -S app && adduser -S app -G app -COPY --from=builder --chown=app:app /app/dist ./dist -COPY --from=builder --chown=app:app /app/node_modules ./node_modules -COPY --from=builder --chown=app:app /app/prisma ./prisma -COPY --from=builder --chown=app:app /app/package.json ./ -USER app -EXPOSE 3000 -CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] +export async function getVenueHeatmap(lat: number, lng: number, radiusM = 10000) { + const results = await prisma.$queryRaw<{ venue_id: string; lat: number; lng: number; active_count: bigint }[]>` + SELECT v.id AS venue_id, ST_Y(v.location::geometry) AS lat, ST_X(v.location::geometry) AS lng, + COUNT(vv.id) AS active_count + FROM venues v + LEFT JOIN venue_visits vv ON vv.venue_id = v.id AND vv.departed_at IS NULL + AND vv.confirmed = true AND vv.arrived_at > NOW() - INTERVAL '2 hours' + WHERE ST_DWithin(v.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusM}) + GROUP BY v.id + ORDER BY active_count DESC + ` + const maxCount = Math.max(1, ...results.map(r => Number(r.active_count))) + return results.map(r => ({ + venueId: r.venue_id, lat: r.lat, lng: r.lng, + activeCount: Number(r.active_count), + intensity: Number(r.active_count) / maxCount, + })) +} ``` ------ - -## UNIVERSAL FLY.TOML - -```toml -# Replace APP_NAME with repo name -app = "APP_NAME" -primary_region = "ewr" +### PATENT 3 — src/services/voice.service.ts -[build] - dockerfile = "Dockerfile" +```typescript +import { prisma } from '../lib/prisma' -[env] - PORT = "3000" - NODE_ENV = "production" +const STALENESS_DAYS = 30 + +export interface AcousticFeatures { + mfccVector: number[] + pitchMean: number + pitchRange: number + pitchVariance: number + speechRate: number + energyMean: number + energyVariance: number + confidence: number +} -[http_service] - internal_port = 3000 - force_https = true - auto_stop_machines = false - auto_start_machines = true - min_machines_running = 1 +export async function storeVoiceProfile( + userId: string, features: AcousticFeatures, s3Key: string, durationS: number +) { + await prisma.voiceProfile.upsert({ + where: { userId }, + create: { + userId, ...features, sampleS3Key: s3Key, sampleDurationS: durationS, + consentGranted: true, consentAt: new Date(), recordedAt: new Date(), + }, + update: { + ...features, sampleS3Key: s3Key, sampleDurationS: durationS, + recordedAt: new Date(), lastComputedAt: new Date(), + }, + }) +} - [http_service.concurrency] - type = "connections" - hard_limit = 500 - soft_limit = 200 +async function getActiveProfile(userId: string) { + const p = await prisma.voiceProfile.findUnique({ where: { userId } }) + if (!p || !p.consentGranted || !p.recordedAt) return null + const ageDays = (Date.now() - p.recordedAt.getTime()) / 86400000 + if (ageDays > STALENESS_DAYS) { + const decay = Math.max(0, 1 - (ageDays - STALENESS_DAYS) / STALENESS_DAYS) + return { ...p, confidence: p.confidence * decay } + } + return p +} -[[vm]] - memory = "512mb" - cpu_kind = "shared" - cpus = 1 +function cosineSim(a: number[], b: number[]): number { + if (a.length !== b.length || a.length === 0) return 0 + const dot = a.reduce((s, ai, i) => s + ai * (b[i] ?? 0), 0) + const magA = Math.sqrt(a.reduce((s, ai) => s + ai * ai, 0)) + const magB = Math.sqrt(b.reduce((s, bi) => s + bi * bi, 0)) + if (magA === 0 || magB === 0) return 0 + return dot / (magA * magB) +} -[checks] - [checks.health] - grace_period = "10s" - interval = "30s" - method = "GET" - path = "/health" - port = 3000 - timeout = "5s" - type = "http" +export async function computeVCS(userAId: string, userBId: string): Promise { + const [pA, pB] = await Promise.all([getActiveProfile(userAId), getActiveProfile(userBId)]) + if (!pA || !pB || pA.confidence < 0.1 || pB.confidence < 0.1) return 0.5 + const mfccRaw = cosineSim(pA.mfccVector, pB.mfccVector) + const mfccScore = (mfccRaw + 1) / 2 + const aMin = (pA.pitchMean ?? 0) - (pA.pitchRange ?? 0) / 2 + const aMax = (pA.pitchMean ?? 0) + (pA.pitchRange ?? 0) / 2 + const bMin = (pB.pitchMean ?? 0) - (pB.pitchRange ?? 0) / 2 + const bMax = (pB.pitchMean ?? 0) + (pB.pitchRange ?? 0) / 2 + const overlapLen = Math.max(0, Math.min(aMax, bMax) - Math.max(aMin, bMin)) + const unionLen = Math.max(aMax, bMax) - Math.min(aMin, bMin) + const pitchScore = unionLen > 0 ? overlapLen / unionLen : 0.5 + const rateDelta = Math.abs((pA.speechRate ?? 0) - (pB.speechRate ?? 0)) + const rateScore = Math.max(0, 1 - rateDelta / 4) + const energyDelta = Math.abs((pA.energyMean ?? 0) - (pB.energyMean ?? 0)) + const energyScore = Math.max(0, 1 - energyDelta / 0.5) + const rawVCS = mfccScore * 0.50 + pitchScore * 0.25 + rateScore * 0.15 + energyScore * 0.10 + const weight = (pA.confidence + pB.confidence) / 2 + return 0.5 + (rawVCS - 0.5) * weight +} ``` ------ +### PATENT 4 — src/services/reveal.service.ts -## UNIVERSAL GITHUB ACTIONS +```typescript +import { createHmac, randomBytes, createCipheriv, createDecipheriv } from 'crypto' +import { prisma } from '../lib/prisma' +import { env } from '../config/env' -```yaml -# .github/workflows/deploy.yml — universal pattern -name: Deploy -on: - push: - branches: [main] +const REVEAL_TTL_HOURS = 24 +const AES_ALGO = 'aes-256-gcm' -jobs: - deploy: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 +function generateCommitment(userId: string, targetId: string, nonce: string): string { + return createHmac('sha256', env.REVEAL_HMAC_SECRET) + .update(`${userId}:${targetId}:${nonce}`) + .digest('hex') +} - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm +function encryptPayload(payload: object, key: Buffer): string { + const iv = randomBytes(12) + const cipher = createCipheriv(AES_ALGO, key, iv) + const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}` +} - - run: npm ci - - run: npx prisma generate - - run: npm run typecheck - - run: npm run lint - - run: npm test - env: - NODE_ENV: test - # repo-specific test env vars +function deriveRevealKey(userAId: string, userBId: string): Buffer { + const pairId = [userAId, userBId].sort().join(':') + return Buffer.from(createHmac('sha256', env.REVEAL_HMAC_SECRET).update(pairId).digest()) +} - - uses: superfly/flyctl-actions/setup-flyctl@master - - run: flyctl deploy --remote-only - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} -``` +export async function submitCommitment(userId: string, targetId: string) { + const nonce = randomBytes(16).toString('hex') + const commitment = generateCommitment(userId, targetId, nonce) + const expiresAt = new Date(Date.now() + REVEAL_TTL_HOURS * 3600000) + await prisma.revealCommitment.upsert({ + where: { userId_targetId: { userId, targetId } }, + create: { userId, targetId, commitment, expiresAt }, + update: { commitment, expiresAt, status: 'pending' }, + }) + await prisma.revealAuditLog.create({ data: { eventType: 'committed', userId, targetId } }) + const counterpart = await prisma.revealCommitment.findUnique({ + where: { userId_targetId: { userId: targetId, targetId: userId } }, + }) + const isMutual = counterpart?.status === 'pending' && counterpart.expiresAt > new Date() + if (isMutual) { + await prisma.$transaction([ + prisma.revealCommitment.update({ where: { userId_targetId: { userId, targetId } }, data: { status: 'matched', revealedAt: new Date() } }), + prisma.revealCommitment.update({ where: { userId_targetId: { userId: targetId, targetId: userId } }, data: { status: 'matched', revealedAt: new Date() } }), + prisma.revealAuditLog.create({ data: { eventType: 'matched', userId, targetId } }), + ]) + } + return { committed: true, alreadyMutual: isMutual } +} ------ +export async function getRevealPayload(requesterId: string, targetId: string) { + const [mine, theirs] = await Promise.all([ + prisma.revealCommitment.findUnique({ where: { userId_targetId: { userId: requesterId, targetId } } }), + prisma.revealCommitment.findUnique({ where: { userId_targetId: { userId: targetId, targetId: requesterId } } }), + ]) + if (mine?.status !== 'matched' || theirs?.status !== 'matched') return null + const target = await prisma.user.findUnique({ + where: { id: targetId }, + select: { displayName: true, fullName: true, photos: true }, + }) + if (!target) return null + const key = deriveRevealKey(requesterId, targetId) + return { encrypted: encryptPayload(target, key) } +} -## UNIVERSAL ERROR BOUNDARY (React Native) +export async function withdrawCommitment(userId: string, targetId: string) { + await prisma.revealCommitment.updateMany({ where: { userId, targetId, status: 'pending' }, data: { status: 'withdrawn' } }) + await prisma.revealAuditLog.create({ data: { eventType: 'withdrawn', userId, targetId } }) +} -Required on every screen root in every RN repo. +export async function expireStaleCommitments(): Promise { + const result = await prisma.revealCommitment.updateMany({ + where: { status: 'pending', expiresAt: { lt: new Date() } }, + data: { status: 'expired' }, + }) + return result.count +} +``` -```typescript -// src/components/ErrorBoundary.tsx — universal -import React, { Component, ReactNode } from 'react' -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native' +### PATENT 5 — src/services/socialGraph.service.ts -interface Props { children: ReactNode; fallback?: ReactNode; onError?: (error: Error) => void } -interface State { hasError: boolean; error: Error | null } +```typescript +import { createHash } from 'crypto' +import { prisma } from '../lib/prisma' -export class ErrorBoundary extends Component { - state: State = { hasError: false, error: null } +function hashPhone(phone: string): string { + const normalized = phone.replace(/\D/g, '') + const e164 = normalized.startsWith('1') ? `+${normalized}` : `+1${normalized}` + return createHash('sha256').update(e164).digest('hex') +} - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error } +export async function uploadContacts(userId: string, rawPhones: string[]) { + const hashes = [...new Set(rawPhones.map(hashPhone))] + await prisma.$executeRaw` + INSERT INTO contact_hashes (user_id, hash) + SELECT ${userId}::uuid, unnest(${hashes}::text[]) + ON CONFLICT (user_id, hash) DO NOTHING + ` + const myProfile = await prisma.user.findUnique({ where: { id: userId }, select: { phone: true } }) + let contactMatches = 0 + if (myProfile?.phone) { + const myHash = hashPhone(myProfile.phone) + const usersWithMyHash = await prisma.contactHash.findMany({ where: { hash: myHash, userId: { not: userId } }, select: { userId: true } }) + for (const { userId: otherId } of usersWithMyHash) { + await prisma.socialExclusion.upsert({ where: { userId_excludedId: { userId, excludedId: otherId } }, create: { userId, excludedId: otherId, reason: 'contact_match' }, update: {} }) + await prisma.socialExclusion.upsert({ where: { userId_excludedId: { userId: otherId, excludedId: userId } }, create: { userId: otherId, excludedId: userId, reason: 'contact_match' }, update: {} }) + contactMatches++ + } } + return { matched: contactMatches, uploaded: hashes.length } +} - componentDidCatch(error: Error, info: React.ErrorInfo) { - console.error('[ErrorBoundary]', error.message, info.componentStack) - this.props.onError?.(error) - // TODO[P1]: wire to Sentry - } +export async function getExcludedIds(userId: string): Promise> { + const exclusions = await prisma.socialExclusion.findMany({ + where: { userId, OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] }, + select: { excludedId: true }, + }) + return new Set(exclusions.map(e => e.excludedId)) +} - reset = () => this.setState({ hasError: false, error: null }) - - render() { - if (this.state.hasError) { - return this.props.fallback ?? ( - - Something went wrong - {this.state.error?.message} - - Try Again - - - ) - } - return this.props.children - } +export async function getMutualConnectionIds(userId: string): Promise> { + const myReveals = await prisma.revealCommitment.findMany({ where: { userId, status: 'matched' }, select: { targetId: true } }) + if (myReveals.length === 0) return new Set() + const myRevealedIds = myReveals.map(r => r.targetId) + const mutuals = await prisma.revealCommitment.findMany({ + where: { userId: { in: myRevealedIds }, status: 'matched', targetId: { not: userId } }, + select: { targetId: true }, + }) + return new Set(mutuals.map(m => m.targetId)) } -const s = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#0D0A14', alignItems: 'center', justifyContent: 'center', padding: 24 }, - title: { fontFamily: 'Cinzel-Regular', fontSize: 20, color: '#C9A84C', marginBottom: 12 }, - message: { fontFamily: 'DMMono-Regular', fontSize: 13, color: '#6B5B7B', textAlign: 'center', marginBottom: 32 }, - button: { borderWidth: 1, borderColor: '#C9A84C', paddingHorizontal: 32, paddingVertical: 12 }, - buttonText: { fontFamily: 'DMMono-Regular', fontSize: 13, color: '#C9A84C', letterSpacing: 2 }, -}) +export async function getFullExclusionSet(userId: string): Promise> { + const [explicit, mutual] = await Promise.all([getExcludedIds(userId), getMutualConnectionIds(userId)]) + return new Set([...explicit, ...mutual]) +} + +export async function addExclusion(userId: string, excludedId: string, reason: 'block' | 'manual' = 'manual', ttlDays?: number) { + const expiresAt = ttlDays ? new Date(Date.now() + ttlDays * 86400000) : null + await prisma.socialExclusion.upsert({ where: { userId_excludedId: { userId, excludedId } }, create: { userId, excludedId, reason, expiresAt }, update: { reason, expiresAt } }) +} ``` ----- -## UNIVERSAL API CLIENT (React Native / Web) - -```typescript -// src/lib/api.ts — universal pattern -const BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? '' - -class ApiClient { - private token: string | null = null +## MATCHING PIPELINE - setToken(token: string) { this.token = token } - clearToken() { this.token = null } +### src/services/matching.service.ts - private async request(method: string, path: string, body?: unknown): Promise { - const headers: Record = { 'Content-Type': 'application/json' } - if (this.token) headers['Authorization'] = `Bearer ${this.token}` +```typescript +import { getNearbyUsers } from './proximity.service' +import { computeCCS } from './circadian.service' +import { computeVAS } from './venue.service' +import { computeVCS } from './voice.service' +import { getFullExclusionSet } from './socialGraph.service' + +const WEIGHTS = { proximity: 0.35, ccs: 0.25, vas: 0.25, vcs: 0.15 } as const + +export interface ScoredMatch { + id: string + displayName: string + distanceMeters: number + circadianScore: number + venueAffinityScore: number + voiceChemistryScore: number + compatibilityScore: number +} - const res = await fetch(`${BASE_URL}${path}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, +export async function getRankedMatches( + userId: string, lat: number, lng: number, radiusMeters = 5000, limit = 50 +): Promise { + const excluded = await getFullExclusionSet(userId) + const nearby = await getNearbyUsers({ userId, lat, lng, radiusMeters, limit }) + const eligible = nearby.filter(u => !excluded.has(u.id)) + if (eligible.length === 0) return [] + const scored = await Promise.all( + eligible.map(async (candidate) => { + const [ccs, vas, vcs] = await Promise.all([ + computeCCS(userId, candidate.id), + computeVAS(userId, candidate.id), + computeVCS(userId, candidate.id), + ]) + return { ...candidate, ccs, vas, vcs } }) + ) + const maxDist = Math.max(1, ...scored.map(u => u.distance_meters)) + return scored + .map(u => { + const proxScore = 1 - u.distance_meters / maxDist + const vcms = proxScore * WEIGHTS.proximity + u.ccs * WEIGHTS.ccs + u.vas * WEIGHTS.vas + u.vcs * WEIGHTS.vcs + return { + id: u.id, displayName: u.displayName, + distanceMeters: Math.round(u.distance_meters), + circadianScore: Math.round(u.ccs * 100), + venueAffinityScore: Math.round(u.vas * 100), + voiceChemistryScore: Math.round(u.vcs * 100), + compatibilityScore: Math.round(vcms * 100), + } + }) + .sort((a, b) => b.compatibilityScore - a.compatibilityScore) +} +``` - if (!res.ok) { - const error = await res.json().catch(() => ({ error: 'Request failed' })) - throw new Error(error.error ?? `HTTP ${res.status}`) - } +### src/services/proximity.service.ts - return res.json() - } +```typescript +import { prisma } from '../lib/prisma' + +interface NearbyUser { + id: string + displayName: string + distance_meters: number + lastSeen: Date +} - get(path: string) { return this.request('GET', path) } - post(path: string, body?: unknown) { return this.request('POST', path, body) } - patch(path: string, body?: unknown) { return this.request('PATCH', path, body) } - delete(path: string) { return this.request('DELETE', path) } +export async function getNearbyUsers({ userId, lat, lng, radiusMeters = 5000, limit = 50 }: { + userId: string; lat: number; lng: number; radiusMeters?: number; limit?: number +}): Promise { + return prisma.$queryRaw` + SELECT u.id, u."displayName", u."lastSeen", + ST_Distance(u.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters + FROM users u + WHERE u.id != ${userId} AND u."isVisible" = true AND u."lastSeen" > NOW() - INTERVAL '24 hours' + AND ST_DWithin(u.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters}) + ORDER BY distance_meters ASC LIMIT ${limit} + ` } -export const api = new ApiClient() +export async function updateUserLocation(userId: string, lat: number, lng: number) { + await prisma.$executeRaw` + UPDATE users SET location = ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326), "lastSeen" = NOW() WHERE id = ${userId} + ` +} ``` ----- -## UNIVERSAL ZUSTAND STORE PATTERN +## SOCKET.IO + +### src/socket/index.ts ```typescript -// src/store/index.ts — universal pattern -import { create } from 'zustand' - -// Every store follows this shape: -// - state fields (typed) -// - actions (synchronous state updates) -// - async operations called from components, NOT stored as actions - -interface AppState { - // Auth - userId: string | null - token: string | null - - // UI - isLoading: boolean - error: string | null - - // Actions - setAuth: (userId: string, token: string) => void - clearAuth: () => void - setLoading: (loading: boolean) => void - setError: (error: string | null) => void -} - -export const useAppStore = create((set) => ({ - userId: null, - token: null, - isLoading: false, - error: null, - - setAuth: (userId, token) => set({ userId, token }), - clearAuth: () => set({ userId: null, token: null }), - setLoading: (isLoading) => set({ isLoading }), - setError: (error) => set({ error }), -})) +import { Server } from 'socket.io' +import { Server as HttpServer } from 'http' +import { env } from '../config/env' +import { socketAuthMiddleware, AuthSocket } from '../middleware/socketAuth' +import { registerPresence } from './presence' +import { registerMessaging } from './messaging' + +export function createSocketServer(httpServer: HttpServer): Server { + const io = new Server(httpServer, { + cors: { origin: env.CORS_ORIGIN, credentials: true }, + transports: ['websocket', 'polling'], + pingTimeout: 20000, + pingInterval: 25000, + }) + io.use(socketAuthMiddleware) + io.on('connection', (socket) => { + const s = socket as AuthSocket + s.join(`user:${s.userId}`) + registerPresence(io, s) + registerMessaging(io, s) + }) + return io +} ``` ------ - -## UNIVERSAL SOCKET.IO CLIENT HOOK +### src/socket/presence.ts ```typescript -// src/hooks/useSocket.ts — universal pattern -import { useEffect, useRef } from 'react' -import { io, Socket } from 'socket.io-client' -import { useAppStore } from '../store' - -const SOCKET_URL = process.env.EXPO_PUBLIC_API_URL ?? '' - -export function useSocket(): Socket | null { - const socketRef = useRef(null) - const { token } = useAppStore() +import { Server } from 'socket.io' +import { AuthSocket } from '../middleware/socketAuth' +import { updateUserLocation } from '../services/proximity.service' +import { logActivityEvent } from '../services/circadian.service' +import { processLocationForVenue } from '../services/venue.service' +import { prisma } from '../lib/prisma' - useEffect(() => { - if (!token) return +export function registerPresence(io: Server, socket: AuthSocket) { + const { userId } = socket + prisma.user.update({ where: { id: userId }, data: { isOnline: true, lastSeen: new Date() } }).catch(console.error) + socket.on('presence:location', async ({ lat, lng }: { lat: number; lng: number }) => { + if (typeof lat !== 'number' || typeof lng !== 'number') return + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return + await Promise.all([ + updateUserLocation(userId, lat, lng), + logActivityEvent(userId, 'location'), + processLocationForVenue(userId, lat, lng), + ]).catch(console.error) + }) + socket.on('presence:visibility', async ({ visible }: { visible: boolean }) => { + await prisma.user.update({ where: { id: userId }, data: { isVisible: visible } }).catch(console.error) + }) + socket.on('app:foreground', () => logActivityEvent(userId, 'foreground').catch(console.error)) + socket.on('app:background', () => logActivityEvent(userId, 'background').catch(console.error)) + socket.on('disconnect', () => { + prisma.user.update({ where: { id: userId }, data: { isOnline: false, lastSeen: new Date() } }).catch(console.error) + }) +} +``` - const socket = io(SOCKET_URL, { - auth: { token }, - transports: ['websocket', 'polling'], - reconnectionAttempts: 5, - reconnectionDelay: 1000, - }) +### src/socket/messaging.ts - socket.on('connect', () => console.log('[Socket] Connected')) - socket.on('disconnect', (reason) => console.log('[Socket] Disconnected:', reason)) - socket.on('connect_error', (err) => console.error('[Socket] Error:', err.message)) +```typescript +import { Server } from 'socket.io' +import { AuthSocket } from '../middleware/socketAuth' +import { prisma } from '../lib/prisma' +import { notify } from '../services/push.service' - socketRef.current = socket +interface MessagePayload { + matchId: string; content: string; type: 'text' | 'media' | 'voice'; mediaUrl?: string +} - return () => { - socket.disconnect() - socketRef.current = null +export function registerMessaging(io: Server, socket: AuthSocket) { + const { userId } = socket + socket.on('chat:join', ({ matchId }: { matchId: string }) => socket.join(`match:${matchId}`)) + socket.on('message:send', async (payload: MessagePayload) => { + const { matchId, content, type, mediaUrl } = payload + const match = await prisma.match.findFirst({ + where: { id: matchId, OR: [{ userId1: userId }, { userId2: userId }], status: 'active' }, + }) + if (!match) { socket.emit('message:error', { error: 'Not authorized' }); return } + const recipientId = match.userId1 === userId ? match.userId2 : match.userId1 + const message = await prisma.message.create({ data: { matchId, senderId: userId, recipientId, content, type, mediaUrl } }) + io.to(`user:${recipientId}`).emit('message:receive', { id: message.id, matchId, senderId: userId, content, type, mediaUrl, createdAt: message.createdAt }) + socket.emit('message:delivered', { id: message.id, matchId }) + const recipientSockets = await io.in(`user:${recipientId}`).fetchSockets() + if (recipientSockets.length === 0) { + const sender = await prisma.user.findUnique({ where: { id: userId }, select: { displayName: true } }) + await notify.newMessage(recipientId, sender?.displayName ?? 'Someone', matchId) } - }, [token]) - - return socketRef.current + }) + socket.on('message:read', async ({ matchId }: { matchId: string }) => { + await prisma.message.updateMany({ where: { matchId, recipientId: userId, readAt: null }, data: { readAt: new Date() } }) + const match = await prisma.match.findUnique({ where: { id: matchId } }) + if (!match) return + const senderId = match.userId1 === userId ? match.userId2 : match.userId1 + io.to(`user:${senderId}`).emit('message:read', { matchId }) + }) + socket.on('typing:start', ({ matchId }: { matchId: string }) => socket.to(`match:${matchId}`).emit('typing:start')) + socket.on('typing:stop', ({ matchId }: { matchId: string }) => socket.to(`match:${matchId}`).emit('typing:stop')) } ``` ----- -## UNIVERSAL S3 PATTERN +## SERVICES + +### src/services/s3.service.ts ```typescript -// src/services/s3.service.ts — universal pattern import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { env } from '../config/env' @@ -741,29 +1072,17 @@ import { randomUUID } from 'crypto' const s3 = new S3Client({ region: env.AWS_REGION, - credentials: { - accessKeyId: env.AWS_ACCESS_KEY_ID, - secretAccessKey: env.AWS_SECRET_ACCESS_KEY, - }, + credentials: { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY }, }) -export async function getPresignedUploadUrl( - userId: string, - folder: string, // e.g. 'profile', 'media', 'audio' - contentType: string, - expiresIn = 300 // 5 min default -) { +type MediaType = 'profile' | 'chat' | 'voice-sample' + +export async function getPresignedUploadUrl(userId: string, mediaType: MediaType, contentType: string) { const ext = contentType.split('/')[1] ?? 'bin' - const objectKey = `${folder}/${userId}/${randomUUID()}.${ext}` - const command = new PutObjectCommand({ - Bucket: env.AWS_S3_BUCKET, - Key: objectKey, - ContentType: contentType, - Metadata: { uploadedBy: userId }, - }) - const uploadUrl = await getSignedUrl(s3, command, { expiresIn }) - const publicUrl = `https://${env.AWS_S3_BUCKET}.s3.${env.AWS_REGION}.amazonaws.com/${objectKey}` - return { uploadUrl, objectKey, publicUrl } + const objectKey = `${mediaType}/${userId}/${randomUUID()}.${ext}` + const command = new PutObjectCommand({ Bucket: env.AWS_S3_BUCKET, Key: objectKey, ContentType: contentType, Metadata: { uploadedBy: userId } }) + const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 }) + return { uploadUrl, objectKey, publicUrl: `https://${env.AWS_S3_BUCKET}.s3.${env.AWS_REGION}.amazonaws.com/${objectKey}` } } export async function getS3Object(objectKey: string): Promise { @@ -781,574 +1100,396 @@ export async function deleteS3Object(objectKey: string) { } ``` ------ - -## UNIVERSAL STRIPE WEBHOOK PATTERN +### src/services/livekit.service.ts ```typescript -// src/routes/payments.ts — universal Stripe webhook pattern -import { Router, Request, Response } from 'express' -import Stripe from 'stripe' +import { AccessToken } from 'livekit-server-sdk' import { env } from '../config/env' -const router = Router() -const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2024-06-20' }) - -// CRITICAL: Must be registered BEFORE express.json() in app.ts -// app.use('/payments/webhook', express.raw({ type: 'application/json' })) -router.post('/webhook', async (req: Request, res: Response) => { - const sig = req.headers['stripe-signature'] - if (!sig) return res.status(400).json({ error: 'Missing stripe-signature' }) - - let event: Stripe.Event - try { - event = stripe.webhooks.constructEvent(req.body, sig, env.STRIPE_WEBHOOK_SECRET) - } catch (err) { - console.error('[Stripe] Webhook signature failed:', (err as Error).message) - return res.status(400).json({ error: 'Invalid signature' }) - } - - try { - await handleStripeEvent(event) - res.json({ received: true }) - } catch (err) { - console.error('[Stripe] Handler failed:', err) - res.status(500).json({ error: 'Handler failed' }) - } -}) - -async function handleStripeEvent(event: Stripe.Event) { - switch (event.type) { - case 'payment_intent.succeeded': - // handle payment - break - case 'customer.subscription.deleted': - // handle cancellation - break - default: - console.log(`[Stripe] Unhandled: ${event.type}`) - } +export function generateLiveKitToken({ userId, roomName, canPublish = true, canSubscribe = true, ttlSeconds = 3600 }: { + userId: string; roomName: string; canPublish?: boolean; canSubscribe?: boolean; ttlSeconds?: number +}): string { + const token = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, { identity: userId, ttl: ttlSeconds }) + token.addGrant({ room: roomName, roomJoin: true, canPublish, canSubscribe, canPublishData: true }) + return token.toJwt() } - -export default router ``` ------ - -## UNIVERSAL PUSH NOTIFICATION PATTERN +### src/services/push.service.ts ```typescript -// src/hooks/usePushNotifications.ts — universal RN pattern -import { useEffect } from 'react' -import * as Notifications from 'expo-notifications' -import * as Device from 'expo-device' -import { Platform } from 'react-native' -import { api } from '../lib/api' - -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: true, - }), -}) - -export function usePushNotifications() { - useEffect(() => { register() }, []) - - const register = async () => { - if (!Device.isDevice) return +import Expo, { ExpoPushMessage } from 'expo-server-sdk' +import { prisma } from '../lib/prisma' - const { status: existing } = await Notifications.getPermissionsAsync() - let finalStatus = existing - if (existing !== 'granted') { - const { status } = await Notifications.requestPermissionsAsync() - finalStatus = status - } - if (finalStatus !== 'granted') return +const expo = new Expo() - if (Platform.OS === 'android') { - await Notifications.setNotificationChannelAsync('default', { - name: 'default', - importance: Notifications.AndroidImportance.MAX, - }) +async function sendPushNotification({ userId, type, title, body, data = {} }: { + userId: string; type: string; title: string; body: string; data?: Record +}) { + const user = await prisma.user.findUnique({ where: { id: userId }, select: { expoPushToken: true, pushEnabled: true } }) + if (!user?.expoPushToken || !user.pushEnabled) return + if (!Expo.isExpoPushToken(user.expoPushToken)) return + const message: ExpoPushMessage = { to: user.expoPushToken, sound: 'default', title, body, data: { type, ...data }, priority: type === 'new_message' ? 'high' : 'normal' } + try { + const chunks = expo.chunkPushNotifications([message]) + for (const chunk of chunks) { + const tickets = await expo.sendPushNotificationsAsync(chunk) + for (const ticket of tickets) { + if (ticket.status === 'error' && ticket.details?.error === 'DeviceNotRegistered') { + await prisma.user.update({ where: { id: userId }, data: { expoPushToken: null } }) + } + } } + } catch (err) { console.error('[Push] Send failed:', err) } +} - const { data: token } = await Notifications.getExpoPushTokenAsync({ - projectId: process.env.EXPO_PUBLIC_PROJECT_ID, - }) +export const notify = { + newMessage: (userId: string, senderName: string, matchId: string) => + sendPushNotification({ userId, type: 'new_message', title: senderName, body: 'Sent you a message', data: { matchId } }), + revealMatched: (userId: string, withName: string, matchId: string) => + sendPushNotification({ userId, type: 'reveal_matched', title: 'Mutual Reveal', body: `You and ${withName} revealed to each other`, data: { matchId } }), + newMatch: (userId: string, matchName: string, matchId: string) => + sendPushNotification({ userId, type: 'new_match', title: 'New Match Nearby', body: `${matchName} is close`, data: { matchId } }), +} - await api.post('/push/register', { token }).catch(console.error) - } +export async function registerPushToken(userId: string, token: string) { + if (!Expo.isExpoPushToken(token)) throw new Error(`Invalid Expo push token: ${token}`) + await prisma.user.update({ where: { id: userId }, data: { expoPushToken: token } }) } ``` ----- -## UNIVERSAL LOCATION HOOK - -```typescript -// src/hooks/useLocation.ts — universal RN pattern -import { useEffect, useRef } from 'react' -import * as Location from 'expo-location' -import { useSocket } from './useSocket' - -export function useLocation() { - const socket = useSocket() - const watchRef = useRef(null) - - const startTracking = async () => { - const { status } = await Location.requestForegroundPermissionsAsync() - if (status !== 'granted') return - - watchRef.current = await Location.watchPositionAsync( - { accuracy: Location.Accuracy.Balanced, timeInterval: 30000, distanceInterval: 50 }, - (location) => { - socket?.emit('presence:location', { - lat: location.coords.latitude, - lng: location.coords.longitude, - }) - } - ) - } +## APP ENTRY POINTS - const stopTracking = () => { - watchRef.current?.remove() - watchRef.current = null - } +### src/app.ts - useEffect(() => () => stopTracking(), []) +```typescript +import express from 'express' +import helmet from 'helmet' +import cors from 'cors' +import { globalLimiter, authLimiter } from './middleware/rateLimit' +import { env } from './config/env' +import healthRouter from './routes/health' +import usersRouter from './routes/users' +import matchesRouter from './routes/matches' +import livekitRouter from './routes/livekit' +import mediaRouter from './routes/media' +import voiceRouter from './routes/voice' +import revealRouter from './routes/reveal' +import socialRouter from './routes/social' +import messagesRouter from './routes/messages' +import venuesRouter from './routes/venues' +import pushRouter from './routes/push' +import paymentsRouter from './routes/payments' - return { startTracking, stopTracking } -} +const app = express() +app.use(helmet()) +app.use(cors({ origin: env.CORS_ORIGIN, credentials: true })) +app.use('/payments/webhook', express.raw({ type: 'application/json' })) +app.use(express.json({ limit: '10kb' })) +app.use(globalLimiter) +app.use('/health', healthRouter) +app.use('/users', usersRouter) +app.use('/matches', matchesRouter) +app.use('/livekit', livekitRouter) +app.use('/media', mediaRouter) +app.use('/voice', voiceRouter) +app.use('/reveal', revealRouter) +app.use('/social', socialRouter) +app.use('/messages', messagesRouter) +app.use('/venues', venuesRouter) +app.use('/push', pushRouter) +app.use('/payments', paymentsRouter) +app.use((_req, res) => res.status(404).json({ error: 'Not found' })) +app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('[Express]', err.message) + res.status(500).json({ error: 'Internal server error' }) +}) +export default app ``` ------ - -## UNIVERSAL ACTIVITY TRACKING HOOK +### src/index.ts ```typescript -// src/hooks/useActivityTracking.ts — universal RN pattern -// Powers CCS (Circadian Compatibility Score) signal accumulation -import { useEffect, useRef } from 'react' -import { AppState, AppStateStatus } from 'react-native' -import { useSocket } from './useSocket' - -export function useActivityTracking() { - const socket = useSocket() - const appState = useRef(AppState.currentState) - - useEffect(() => { - const sub = AppState.addEventListener('change', (nextState) => { - if (appState.current === 'background' && nextState === 'active') { - socket?.emit('app:foreground') - } else if (nextState === 'background') { - socket?.emit('app:background') - } - appState.current = nextState - }) - return () => sub.remove() - }, [socket]) -} -``` - ------ - -## UNIVERSAL ROOT LAYOUT PATTERN (Expo) +import './config/env' +import http from 'http' +import app from './app' +import { createSocketServer } from './socket' +import { env } from './config/env' -```typescript -// app/_layout.tsx — universal Expo root layout -import { ErrorBoundary } from '../src/components/ErrorBoundary' -import { useActivityTracking } from '../src/hooks/useActivityTracking' -import { usePushNotifications } from '../src/hooks/usePushNotifications' - -export default function RootLayout() { - // Wire universal hooks - useActivityTracking() // CCS signal accumulation - usePushNotifications() // push token registration - - return ( - - {/* repo-specific navigator */} - - ) -} -``` +const httpServer = http.createServer(app) +const io = createSocketServer(httpServer) +app.set('io', io) ------ +httpServer.listen(env.PORT, () => { + console.log(`ANL API [${env.NODE_ENV}] — port ${env.PORT}`) +}) -## UNIVERSAL TSCONFIG - -```json -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "module": "commonjs", - "moduleResolution": "node", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +const shutdown = async (signal: string) => { + console.log(`${signal} — shutting down`) + io.close() + httpServer.close(() => process.exit(0)) + setTimeout(() => process.exit(1), 10000) } -``` - ------ -## UNIVERSAL PACKAGE.JSON SCRIPTS - -```json -{ - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc --project tsconfig.build.json", - "start": "node dist/index.js", - "typecheck": "tsc --noEmit", - "lint": "eslint src --ext .ts,.tsx --fix", - "test": "vitest run", - "test:watch": "vitest", - "db:migrate": "prisma migrate deploy", - "db:generate": "prisma generate", - "db:studio": "prisma studio", - "db:push": "prisma db push" - } -} +process.on('SIGTERM', () => shutdown('SIGTERM')) +process.on('SIGINT', () => shutdown('SIGINT')) ``` ----- -## UNIVERSAL .GITIGNORE +## NIGHTLY JOB -``` -# Dependencies -node_modules/ -.pnp -.pnp.js - -# Build -dist/ -build/ -.expo/ -.next/ - -# Environment -.env -.env.local -.env.production -.env.*.local - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# Testing -coverage/ -.nyc_output/ - -# Prisma -prisma/*.db -prisma/*.db-journal - -# Expo -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision -*.orig.* -web-build/ -``` +### src/jobs/nightly.ts ------ +```typescript +import '../config/env' +import { prisma } from '../lib/prisma' +import { recomputeProfile } from '../services/circadian.service' +import { expireStaleCommitments } from '../services/reveal.service' -## UNIVERSAL SECURITY CHECKLIST +async function run() { + const start = Date.now() + console.log('[NIGHTLY] Started:', new Date().toISOString()) + const errors: string[] = [] -Applied to every new repo and every PR: + // Recompute stale circadian profiles + const stale = await prisma.circadianProfile.findMany({ + where: { lastComputedAt: { lt: new Date(Date.now() - 6 * 3600000) } }, + select: { userId: true }, + }) + for (const { userId } of stale) { + try { await recomputeProfile(userId) } + catch (e) { errors.push(`circadian:${userId}: ${(e as Error).message}`) } + } -``` -AUTH -[ ] All routes behind requireAuth middleware -[ ] Socket.io behind socketAuthMiddleware -[ ] Auth errors return 401 with no detail leakage -[ ] Token expiry handled gracefully on client - -INPUT VALIDATION -[ ] Zod schema on every request body -[ ] Type coercion for numeric query params -[ ] Array length limits on all bulk operations -[ ] File type + size validation on all uploads - -SECRETS -[ ] All secrets in env vars — never in source -[ ] .env in .gitignore — committed only .env.example -[ ] Secrets set via fly secrets — never in fly.toml -[ ] No secrets in logs or error messages - -RATE LIMITING -[ ] globalLimiter on all routes -[ ] authLimiter on auth routes -[ ] Custom limiters on expensive operations - -HEADERS -[ ] helmet() applied in app.ts -[ ] CORS origin locked to known domains -[ ] force_https = true in fly.toml - -DATABASE -[ ] No raw string interpolation in queries -[ ] Prisma parameterized queries always -[ ] PostGIS raw queries use tagged template literals -[ ] Connection pool sized to VM capacity -``` + // Expire reveal commitments + const expired = await expireStaleCommitments().catch(e => { errors.push(`reveal: ${e.message}`); return 0 }) ------ + // Cleanup old data + await prisma.$executeRaw`DELETE FROM activity_events WHERE created_at < NOW() - INTERVAL '90 days'` + await prisma.$executeRaw`DELETE FROM venue_visits WHERE departed_at IS NOT NULL AND departed_at < NOW() - INTERVAL '30 days'` + await prisma.socialExclusion.deleteMany({ where: { expiresAt: { lt: new Date() } } }) -## UNIVERSAL PERFORMANCE CHECKLIST + console.log('[NIGHTLY] Complete:', { circadianRecomputed: stale.length, expired, errors, durationMs: Date.now() - start }) + if (errors.length > 0) console.error('[NIGHTLY] Errors:', errors) +} -``` -DATABASE -[ ] EXPLAIN ANALYZE run on all new queries -[ ] Indexes on all foreign keys -[ ] GIST indexes on all PostGIS geometry columns -[ ] Prisma select only required fields (no select *) -[ ] Pagination on all list endpoints - -REACT NATIVE -[ ] No blocking operations on JS thread -[ ] Reanimated 3 for all animations -[ ] FlatList with getItemLayout for long lists -[ ] useMemo on expensive computations -[ ] useCallback on all event handlers passed as props -[ ] Images lazy loaded with proper cache headers - -BACKEND -[ ] Promise.all for parallel independent operations -[ ] Never await in a loop (use Promise.all) -[ ] Redis cache for expensive repeated queries -[ ] Response compression (compression middleware) -[ ] Keep-alive connections to DB pool +run().then(() => process.exit(0)).catch(e => { console.error('[NIGHTLY] Fatal:', e); process.exit(1) }) ``` ----- -## UNIVERSAL TELEMETRY CONTRACT +## FRONTEND TOKENS -Every non-trivial module must answer at runtime: +### src/theme/tokens.ts ```typescript -// Telemetry interface — every service implements this -interface TelemetrySignals { - // HEALTH — Am I alive? - health(): Promise<{ status: 'ok' | 'degraded' | 'dead'; latency_ms: number }> - - // PRESSURE — How hard am I working? - pressure(): { requestsPerMin: number; avgLatency_ms: number; queueDepth: number } - - // EFFICIENCY — What am I leaking? - efficiency(): { memoryMB: number; openHandles: number; cacheHitRate: number } +export const colors = { + bg: '#0D0A14', bgElevated: '#13101C', bgCard: '#1A1625', + border: '#2A2040', borderSubtle: '#1E1830', + accent: '#8B5CF6', accentWarm: '#C9A84C', accentMuted: '#4C3580', + text: '#F0EBF8', textMuted: '#7B6B9A', textDim: '#4A3D66', + danger: '#EF4444', success: '#10B981', reveal: '#C9A84C', +} as const - // FAILURE — What went wrong? - // Emit structured errors: { code, message, context, timestamp, traceId } +export const fonts = { + display: 'Cinzel-Regular', displayB: 'Cinzel-Bold', + ui: 'DMMono-Regular', uiM: 'DMMono-Medium', +} as const - // TRACE — Where am I in execution? - // Propagate traceId through all async boundaries -} +export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48 } as const +export const radius = { sm: 4, md: 8, lg: 16, full: 9999 } as const ``` ----- -## REPO-SPECIFIC OVERRIDES +## INFRASTRUCTURE -### ANL — AllNightLong +### fly.toml -``` -Auth: Supabase (anonymous-first) -DB: PostgreSQL + PostGIS (spatial queries mandatory) -Deploy: Fly.io ewr region -Accent: Gold (#C9A84C) primary, Violet (#8B5CF6) secondary -Special: 5 patent features — VCMS+ pipeline — do not modify weights without instruction -Patents: CCS + VAS + VCS + Cryptographic Mutual Reveal + Social Graph Exclusion -VCMS+: Proximity(0.35) + CCS(0.25) + VAS(0.25) + VCS(0.15) -``` +```toml +app = "anl-api" +primary_region = "ewr" -### game-on +[build] + dockerfile = "Dockerfile" -``` -Auth: JWT (custom) -DB: PostgreSQL + Prisma -Deploy: Fly.io -Special: Real-money gaming — KYC required — Stripe escrow wallet -Games: 8 games — tournament system — 3-tier paywall (Free/Plus/Premium) -Accent: Gold (#C9A84C) -``` +[env] + PORT = "3000" + NODE_ENV = "production" -### VAULT +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = false + auto_start_machines = true + min_machines_running = 1 -``` -Auth: JWT + WireGuard keypair -DB: PostgreSQL + Prisma -Deploy: Fly.io -Special: WireGuard peer management — kill switch — libsodium keypair -Accent: Gold (#C9A84C) -``` + [http_service.concurrency] + type = "connections" + hard_limit = 500 + soft_limit = 200 -### luminary +[[vm]] + memory = "512mb" + cpu_kind = "shared" + cpus = 1 -``` -Auth: Supabase -Deploy: Vercel (AI Gateway) -Special: Gemini API — batch generation — no traditional DB needed -Accent: Violet (#8B5CF6) +[checks] + [checks.health] + grace_period = "10s" + interval = "30s" + method = "GET" + path = "/health" + port = 3000 + timeout = "5s" + type = "http" ``` -### FORGE +### Dockerfile -``` -Auth: Supabase -Deploy: Vercel -Special: Groq API (Llama 3.3 70B) — terminal aesthetic — system prompt tuned for cloudygetty-ai stack -Accent: Gold (#C9A84C) +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +RUN apk add --no-cache openssl +COPY package*.json ./ +RUN npm ci --ignore-scripts +COPY . . +RUN npx prisma generate +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +RUN apk add --no-cache openssl +RUN addgroup -S anl && adduser -S anl -G anl +COPY --from=builder --chown=anl:anl /app/dist ./dist +COPY --from=builder --chown=anl:anl /app/node_modules ./node_modules +COPY --from=builder --chown=anl:anl /app/prisma ./prisma +COPY --from=builder --chown=anl:anl /app/package.json ./ +USER anl +EXPOSE 3000 +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] ``` -### don't-reneg-on-me +### .github/workflows/fly-deploy.yml -``` -Auth: Supabase -Deploy: Vercel -Special: Card game rules engine — felt/casino aesthetic — Cinzel Decorative font -Accent: Crimson (#9B2335) on felt green +```yaml +name: ANL Deploy +on: + push: + branches: [main] +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ``` -### precrime +----- + +## VCMS+ SCORING REFERENCE ``` -Lang: Go (orchestration) + Rust (hot-path) + TypeScript (client) -Deploy: Fly.io -Special: Static analysis engine — Contract Graph — Suspicion Engine — Reality Check -Market: FinTech primary — Audit/Gate/Org tiers -No RN, no Prisma, no Supabase -``` +VCMS+ = Proximity(0.35) + CCS(0.25) + VAS(0.25) + VCS(0.15) -### echo +CCS = cosine_similarity(activityVector_A, activityVector_B) × confidence_weight + cold start (confidence < 0.1) → 0.5 neutral -``` -Lang: TypeScript (compiler) — output configurable -Deploy: npm package -Special: Vite plugin — esbuild backend — JSX-like syntax — $ reactive prefix -No backend, no DB, no auth -``` +VAS = cosine_similarity(venueAffinityVector_A, venueAffinityVector_B) + no shared venues → 0.5 neutral -### crowned-lion +VCS = mfcc_similarity(0.50) + pitch_overlap(0.25) + speech_rate(0.15) + energy(0.10) + × confidence_weight + no consent or sample → 0.5 neutral -``` -Lang: Plain JS only — no TypeScript — no framework -Deploy: Vercel (static) -Special: HTML5 Canvas — PWA — NJ-compliant social casino -No React, no Node, no Prisma +Social Graph Exclusion: + excluded = explicit_blocks ∪ contact_matches ∪ mutual_connections + applied BEFORE proximity query — silent, bidirectional + +Cryptographic Mutual Reveal: + commitment = HMAC-SHA256(userId:targetId:nonce, REVEAL_HMAC_SECRET) + reveal_key = HMAC-SHA256(sorted(userA, userB), REVEAL_HMAC_SECRET) + payload = AES-256-GCM(target_profile, reveal_key) + match = both commitments verified + TTL not expired ``` ----- -## DEPLOYMENT COMMANDS REFERENCE - -```bash -# Fly.io — new repo -fly launch --no-deploy --name REPO_NAME --region ewr -fly secrets set KEY=value KEY2=value2 -fly deploy - -# Fly.io — existing repo -fly deploy -fly logs -fly status -curl https://REPO_NAME.fly.dev/health - -# Vercel — new repo -vercel --prod - -# Database -npx prisma migrate dev --name migration_name # development -npx prisma migrate deploy # production (runs in Dockerfile CMD) -npx prisma generate # after schema changes -npx prisma studio # GUI - -# GitHub push pattern (every session must end with this) -git add . -git commit -m "feat(scope): description" -git push origin main +## PATENT CLAIMS REFERENCE + ``` +Patent 1 — CCS (Circadian Compatibility Score) + Method: Computing chronobiological compatibility from in-app behavioral + activity vectors using cosine similarity on 24-element temporal arrays + with confidence-weighted cold-start neutral default. ------ +Patent 2 — VAS (Venue Affinity Score) + Method: Matching via co-presence probability at real-world venues + using dwell-confirmed visit history with exponential decay weighting. -## COMMIT MESSAGE FORMAT +Patent 3 — VCS (Voice Chemistry Score) + Method: Acoustic feature extraction (MFCC, pitch, speech rate, energy) + with cross-user spectral compatibility scoring as a matching signal. -All repos use conventional commits: +Patent 4 — Cryptographic Mutual Reveal + Method: Commitment scheme using HMAC-SHA256 with TTL-bounded mutual + verification before identity disclosure via AES-256-GCM. -``` -feat(scope): add new feature -fix(scope): fix bug -chore(scope): maintenance, deps, config -refactor(scope): code improvement without behavior change -perf(scope): performance improvement -test(scope): add or update tests -docs(scope): documentation update -security(scope): security fix - -scope = repo area: auth, api, ui, db, infra, socket, patent, etc. - -Examples: - feat(anl/ccs): add confidence decay for stale profiles - fix(game-on/wallet): prevent double-charge on network retry - chore(vault/deps): upgrade livekit-server-sdk to 2.0 - security(anl/auth): add rate limit to reveal endpoint +Patent 5 — Social Graph Exclusion Engine + Method: Silent exclusion filter using hashed contact matching, + mutual connection graph traversal, and configurable explicit blocks. + +Filing targets: USPTO Class 9 (software) + Class 45 (dating services) +Priority date: file provisional immediately — all 5 features complete ``` ----- -## SESSION END CHECKLIST - -Every Claude Code session ends with: - -``` -[ ] All new files typechecked (npx tsc --noEmit) -[ ] All new routes tested (curl or Supertest) -[ ] No console.log left in production paths -[ ] Error boundaries on all new RN screens -[ ] Telemetry wired on all new services -[ ] .env.example updated if new vars added -[ ] PROJECT.md updated with new file entries -[ ] TODO.md updated — completed tasks checked, new tasks added -[ ] git add . && git commit -m "..." && git push origin main +## DEPLOYMENT SEQUENCE + +``` +1. fly launch --no-deploy --name anl-api --region ewr +2. fly secrets set \ + DATABASE_URL="..." \ + DIRECT_URL="..." \ + SUPABASE_URL="..." \ + SUPABASE_ANON_KEY="..." \ + SUPABASE_SERVICE_ROLE_KEY="..." \ + SUPABASE_JWT_SECRET="..." \ + LIVEKIT_API_KEY="..." \ + LIVEKIT_API_SECRET="..." \ + LIVEKIT_WS_URL="..." \ + AWS_ACCESS_KEY_ID="..." \ + AWS_SECRET_ACCESS_KEY="..." \ + AWS_REGION="us-east-1" \ + AWS_S3_BUCKET="anl-media" \ + STRIPE_SECRET_KEY="..." \ + STRIPE_WEBHOOK_SECRET="..." \ + JWT_SECRET="..." \ + REVEAL_HMAC_SECRET="..." \ + CORS_ORIGIN="..." +3. fly deploy +4. curl https://anl-api.fly.dev/health +5. npx expo build --platform ios +6. Submit to TestFlight +7. File provisional patent — USPTO EFS-Web ``` ----- -*CLOUDYGETTY-AI UNIVERSAL SPECS v1.0* -*Sentinel Engine v6.0 · ENTROPY-ZERO* -*Applies org-wide. Every repo. Every session.* -*Last updated: 2026-05-16* +*ANL SPECS v1.0 — Sentinel Engine v6.0 ENTROPY-ZERO* +*All implementations verified. All patents documented. Deploy when ready.*