chore(cleanup): remove the unused server/ Express backend (Tier 3)
server/ was a standalone Express webhook/health service (couples-connect-server, 17 files) that duplicated the Firebase Cloud Functions — entitlement logic, FCM sends, an answer listener, a webhook route — and was documented as "not client-facing… most teams will not need it." It hadn't been touched since 2026-06-20 (pre-dating the whole v2 Functions migration), had no deploy/CI wiring, and was a standing source of "which backend is real?" confusion. Cloud Functions are the sole backend. Removed the directory + all doc references so nothing dangles: README + Engineering Reference Manual directory trees and the "Optional Express server" section; the iOS ARCHITECTURE_AUDIT intro, its Section 8 (now a "removed" note), and the file-count table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
23cd213953
commit
42fba641f1
|
|
@ -272,7 +272,6 @@ files under `qa/`, never in the tracked README or public docs.
|
||||||
app/ Android app
|
app/ Android app
|
||||||
iphone/ iOS app
|
iphone/ iOS app
|
||||||
functions/ Firebase Cloud Functions
|
functions/ Firebase Cloud Functions
|
||||||
server/ Optional Express service
|
|
||||||
seed/ Question content and seed tooling
|
seed/ Question content and seed tooling
|
||||||
scripts/ Static QA scanners
|
scripts/ Static QA scanners
|
||||||
qa/ Local-only emulator fixture notes and smoke helpers
|
qa/ Local-only emulator fixture notes and smoke helpers
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,6 @@ There is no `auth/` module. Authentication is handled entirely by the Firebase A
|
||||||
firestore.rules # Security rules (single source of truth)
|
firestore.rules # Security rules (single source of truth)
|
||||||
firestore.indexes.json # Composite indexes and TTL field overrides
|
firestore.indexes.json # Composite indexes and TTL field overrides
|
||||||
seed/ # Question pack JSON and local DB generation
|
seed/ # Question pack JSON and local DB generation
|
||||||
server/ # Optional Express webhook/health service (not client-facing)
|
|
||||||
docs/ # This manual, QA notes, release prep, store assets
|
docs/ # This manual, QA notes, release prep, store assets
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1163,10 +1162,6 @@ firebase deploy --only functions
|
||||||
|
|
||||||
`dist/` is committed so the deployed function code is reproducible without running `npm run build` at deploy time.
|
`dist/` is committed so the deployed function code is reproducible without running `npm run build` at deploy time.
|
||||||
|
|
||||||
### Optional Express server
|
|
||||||
|
|
||||||
`server/` is an Express webhook/health service that is not client-facing. It exists for environments where Cloud Functions are not the right deployment surface (e.g. custom VPC). Most teams will not need it.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Engineering conventions
|
## Engineering conventions
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Closer iOS App — Architecture Audit
|
# Closer iOS App — Architecture Audit
|
||||||
|
|
||||||
> Generated from the Android (Kotlin) codebase at `app/src/main/java/app/closer/`,
|
> Generated from the Android (Kotlin) codebase at `app/src/main/java/app/closer/`,
|
||||||
> Cloud Functions at `functions/src/`, and secondary Express server at `server/src/`.
|
> Cloud Functions at `functions/src/`.
|
||||||
>
|
>
|
||||||
> Intended audience: SwiftUI developer building the iOS port without reading Kotlin.
|
> Intended audience: SwiftUI developer building the iOS port without reading Kotlin.
|
||||||
|
|
||||||
|
|
@ -631,26 +631,9 @@ func acceptInvite(code: String, recoveryPhrase: String?) async throws -> String
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Secondary Express Server
|
## 8. Secondary Express Server (removed)
|
||||||
|
|
||||||
The `server/` directory contains a standalone Express server, **NOT** Firebase Cloud Functions. It runs separately.
|
The `server/` Express backend was **removed on 2026-07-09** — it duplicated the Firebase Cloud Functions and was never client-facing. **Cloud Functions (Section 7) are the sole backend contract** for the iOS app.
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `src/index.ts` | Express app init, helmet, morgan, raw body capture |
|
|
||||||
| `src/config/env.ts` | ENV validation |
|
|
||||||
| `src/config/firebase.ts` | Firebase Admin SDK init (for Firestore + FCM access) |
|
|
||||||
| `src/routes/health.ts` | `GET /health` |
|
|
||||||
| `src/routes/webhooks.ts` | Webhook endpoint (rate-limited) |
|
|
||||||
| `src/middleware/rateLimiter.ts` | Rate limiting middleware |
|
|
||||||
| `src/services/entitlement.ts` | Server-side entitlement logic (redundant with Cloud Functions?) |
|
|
||||||
| `src/services/fcm.ts` | FCM send helpers |
|
|
||||||
| `src/listeners/answerListener.ts` | Firestore listener for answer changes |
|
|
||||||
| `src/types/index.ts` | Shared TypeScript types |
|
|
||||||
|
|
||||||
**iOS Impact:** The Express server is not client-facing — it handles internal webhooks and secondary Firestore listeners. iOS app only needs to call Firebase through the Firebase iOS SDK and Cloud Functions (Section 7). The Express server runs independently.
|
|
||||||
|
|
||||||
**Note:** The `/server` directory contains code that overlaps with Firebase Cloud Functions. The iOS app should treat Cloud Functions (Section 7) as the authoritative backend contract.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1101,7 +1084,6 @@ iphone/
|
||||||
| Cloud Functions (webhook) | 1 |
|
| Cloud Functions (webhook) | 1 |
|
||||||
| Domain models to port | 35 |
|
| Domain models to port | 35 |
|
||||||
| Crypto classes to port | 10 (recommended: skip for MVP) |
|
| Crypto classes to port | 10 (recommended: skip for MVP) |
|
||||||
| Express server files | 11 (not client-facing) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# Firebase Admin SDK — download from Firebase Console > Project Settings > Service Accounts
|
|
||||||
FIREBASE_SERVICE_ACCOUNT_PATH=/run/secrets/firebase-service-account.json
|
|
||||||
# Or paste the JSON inline (useful for cloud envs):
|
|
||||||
# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
|
|
||||||
|
|
||||||
# Your Firebase project ID
|
|
||||||
FIREBASE_PROJECT_ID=your-project-id
|
|
||||||
|
|
||||||
# RevenueCat webhook shared secret (legacy; prefer signing key below)
|
|
||||||
# Set this in RevenueCat Dashboard > Project > Integrations > Webhooks > Authorization header
|
|
||||||
REVENUECAT_WEBHOOK_SECRET=your-revenuecat-webhook-secret
|
|
||||||
|
|
||||||
# RevenueCat webhook Ed25519 signing key (modern, preferred)
|
|
||||||
# Found in RevenueCat Dashboard > Project > Integrations > Webhooks > Webhook signing
|
|
||||||
REVENUECAT_SIGNING_KEY=base64-ed25519-public-key
|
|
||||||
|
|
||||||
# Comma-separated product IDs that grant premium access
|
|
||||||
REVENUECAT_PREMIUM_PRODUCT_IDS=closer_premium_monthly,closer_premium_yearly
|
|
||||||
|
|
||||||
# Rate limits (requests per minute)
|
|
||||||
RATE_LIMIT_WEBHOOK=10
|
|
||||||
RATE_LIMIT_HEALTH=30
|
|
||||||
RATE_LIMIT_DEFAULT=60
|
|
||||||
|
|
||||||
# Server port (default 8080)
|
|
||||||
PORT=8080
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
.env
|
|
||||||
firebase-service-account.json
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY tsconfig.json ./
|
|
||||||
COPY src ./src
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ── Production image ────────────────────────────────────────────────────────
|
|
||||||
FROM node:20-alpine AS production
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
|
|
||||||
# Non-root user for security
|
|
||||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
services:
|
|
||||||
server:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
target: production
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
secrets:
|
|
||||||
- firebase-service-account
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
# Local dev only — hot-reload without rebuilding the image
|
|
||||||
server-dev:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
target: builder
|
|
||||||
command: npm run dev
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
secrets:
|
|
||||||
- firebase-service-account
|
|
||||||
volumes:
|
|
||||||
- ./src:/app/src:ro
|
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
firebase-service-account:
|
|
||||||
file: ./firebase-service-account.json
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"name": "couples-connect-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"lint": "eslint src --ext .ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.19.2",
|
|
||||||
"express-rate-limit": "^7.5.0",
|
|
||||||
"firebase-admin": "^12.2.0",
|
|
||||||
"helmet": "^7.1.0",
|
|
||||||
"morgan": "^1.10.0",
|
|
||||||
"dotenv": "^16.4.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/morgan": "^1.9.9",
|
|
||||||
"@types/node": "^20.14.2",
|
|
||||||
"ts-node-dev": "^2.0.0",
|
|
||||||
"typescript": "^5.4.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import 'dotenv/config'
|
|
||||||
|
|
||||||
const requiredEnvVars = ['FIREBASE_PROJECT_ID'] as const
|
|
||||||
const recommendedEnvVars = [
|
|
||||||
'REVENUECAT_WEBHOOK_SECRET',
|
|
||||||
'REVENUECAT_SIGNING_KEY',
|
|
||||||
'REVENUECAT_PREMIUM_PRODUCT_IDS',
|
|
||||||
'RATE_LIMIT_WEBHOOK',
|
|
||||||
'RATE_LIMIT_HEALTH',
|
|
||||||
'RATE_LIMIT_DEFAULT',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type RequiredEnvVar = (typeof requiredEnvVars)[number]
|
|
||||||
type RecommendedEnvVar = (typeof recommendedEnvVars)[number]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that all required environment variables are set.
|
|
||||||
* Throws an error with details if any are missing.
|
|
||||||
* Logs warnings for missing recommended vars.
|
|
||||||
*/
|
|
||||||
export function validateEnv(): void {
|
|
||||||
const missing: RequiredEnvVar[] = []
|
|
||||||
const missingRecommended: RecommendedEnvVar[] = []
|
|
||||||
|
|
||||||
for (const varName of requiredEnvVars) {
|
|
||||||
const value = process.env[varName]
|
|
||||||
if (!value || value.trim() === '') {
|
|
||||||
missing.push(varName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const varName of recommendedEnvVars) {
|
|
||||||
const value = process.env[varName]
|
|
||||||
if (!value || value.trim() === '') {
|
|
||||||
missingRecommended.push(varName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
const message = missing.map(v => ` - ${v}`).join('\n')
|
|
||||||
throw new Error(`Missing required environment variables:\n${message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log warnings for missing recommended vars (not fatal)
|
|
||||||
if (missingRecommended.length > 0) {
|
|
||||||
console.warn('[env] Missing recommended environment variables (some features disabled):')
|
|
||||||
for (const varName of missingRecommended) {
|
|
||||||
console.warn(` - ${varName}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely retrieves a required environment variable.
|
|
||||||
* Throws if not set (after validateEnv has been called).
|
|
||||||
*/
|
|
||||||
export function getEnv(varName: RequiredEnvVar): string {
|
|
||||||
const value = process.env[varName]
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Environment variable ${varName} is not set`)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely retrieves an environment variable (required or recommended).
|
|
||||||
* Returns empty string for missing recommended vars (allowing defaults).
|
|
||||||
*/
|
|
||||||
export function getEnvValue(varName: string): string {
|
|
||||||
const value = process.env[varName]
|
|
||||||
return value ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a rate limit value from env var, with default fallback.
|
|
||||||
* Warns if env var is missing and uses default.
|
|
||||||
*/
|
|
||||||
export function getRateLimit(varName: string, defaultValue: number): number {
|
|
||||||
const value = process.env[varName]
|
|
||||||
if (!value) {
|
|
||||||
console.warn(`[env] ${varName} not set, using default: ${defaultValue} requests/min`)
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
const parsed = parseInt(value, 10)
|
|
||||||
if (isNaN(parsed) || parsed <= 0) {
|
|
||||||
console.warn(`[env] ${varName} has invalid value '${value}', using default: ${defaultValue} requests/min`)
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
import * as fs from 'fs'
|
|
||||||
|
|
||||||
let initialized = false
|
|
||||||
|
|
||||||
export function initFirebase(): void {
|
|
||||||
if (initialized) return
|
|
||||||
|
|
||||||
const serviceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT_PATH
|
|
||||||
const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON
|
|
||||||
|
|
||||||
let credential: admin.credential.Credential
|
|
||||||
|
|
||||||
if (serviceAccountJson) {
|
|
||||||
const parsed = JSON.parse(serviceAccountJson)
|
|
||||||
credential = admin.credential.cert(parsed)
|
|
||||||
} else if (serviceAccountPath && fs.existsSync(serviceAccountPath)) {
|
|
||||||
credential = admin.credential.cert(serviceAccountPath)
|
|
||||||
} else {
|
|
||||||
// Falls back to Application Default Credentials (works on GCP/Cloud Run)
|
|
||||||
credential = admin.credential.applicationDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
admin.initializeApp({
|
|
||||||
credential,
|
|
||||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
|
||||||
})
|
|
||||||
|
|
||||||
initialized = true
|
|
||||||
}
|
|
||||||
|
|
||||||
export const db = (): FirebaseFirestore.Firestore => admin.firestore()
|
|
||||||
export const messaging = (): admin.messaging.Messaging => admin.messaging()
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import 'dotenv/config'
|
|
||||||
import express from 'express'
|
|
||||||
import helmet from 'helmet'
|
|
||||||
import morgan from 'morgan'
|
|
||||||
import { initFirebase } from './config/firebase'
|
|
||||||
import { startAnswerListener } from './listeners/answerListener'
|
|
||||||
import { validateEnv } from './config/env'
|
|
||||||
import { webhookLimiter, healthLimiter } from './middleware/rateLimiter'
|
|
||||||
import healthRouter from './routes/health'
|
|
||||||
import webhooksRouter from './routes/webhooks'
|
|
||||||
|
|
||||||
// Validate required env vars before starting
|
|
||||||
try {
|
|
||||||
validateEnv()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[startup] Env validation failed:')
|
|
||||||
console.error(err)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
initFirebase()
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
const PORT = parseInt(process.env.PORT ?? '8080', 10)
|
|
||||||
|
|
||||||
// Capture raw body BEFORE express.json() for webhook signature verification
|
|
||||||
app.use(express.json({
|
|
||||||
verify: (req: express.Request, res: express.Response, body: Buffer) => {
|
|
||||||
(req as any).rawBody = body
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
app.use(helmet())
|
|
||||||
app.use(morgan('combined'))
|
|
||||||
|
|
||||||
// Rate limiting middleware (applied before routes)
|
|
||||||
app.use('/webhooks', webhookLimiter)
|
|
||||||
app.use('/health', healthLimiter)
|
|
||||||
|
|
||||||
app.use('/health', healthRouter)
|
|
||||||
app.use('/webhooks', webhooksRouter)
|
|
||||||
|
|
||||||
const server = app.listen(PORT, () => {
|
|
||||||
console.log(`[server] listening on port ${PORT}`)
|
|
||||||
startAnswerListener()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
console.log('[server] SIGTERM received, shutting down')
|
|
||||||
server.close(() => process.exit(0))
|
|
||||||
})
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('[server] SIGINT received, shutting down')
|
|
||||||
server.close(() => process.exit(0))
|
|
||||||
})
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
import { db } from '../config/firebase'
|
|
||||||
import { sendPartnerAnsweredNotification } from '../services/fcm'
|
|
||||||
import { CoupleDoc, UserDoc } from '../types'
|
|
||||||
|
|
||||||
export function startAnswerListener(): () => void {
|
|
||||||
console.log('[listener] watching answer writes...')
|
|
||||||
|
|
||||||
// Watch every answer document across all couples and threads
|
|
||||||
const unsubscribe = db()
|
|
||||||
.collectionGroup('answers')
|
|
||||||
.onSnapshot(
|
|
||||||
async (snapshot) => {
|
|
||||||
const creates = snapshot.docChanges().filter((c) => c.type === 'added')
|
|
||||||
|
|
||||||
for (const change of creates) {
|
|
||||||
const answerRef = change.doc.ref
|
|
||||||
// Path: couples/{coupleId}/question_threads/{threadId}/answers/{userId}
|
|
||||||
const threadRef = answerRef.parent.parent
|
|
||||||
const coupleRef = threadRef?.parent.parent
|
|
||||||
|
|
||||||
if (!coupleRef || !threadRef) continue
|
|
||||||
|
|
||||||
const answererId = answerRef.id
|
|
||||||
const coupleId = coupleRef.id
|
|
||||||
|
|
||||||
try {
|
|
||||||
const coupleSnap = await coupleRef.get()
|
|
||||||
if (!coupleSnap.exists) continue
|
|
||||||
|
|
||||||
const couple = coupleSnap.data() as CoupleDoc
|
|
||||||
const partnerId = couple.userIds.find((id) => id !== answererId)
|
|
||||||
if (!partnerId) continue
|
|
||||||
|
|
||||||
// Only notify if partner has NOT already answered this thread
|
|
||||||
const partnerAnswerSnap = await threadRef
|
|
||||||
.collection('answers')
|
|
||||||
.doc(partnerId)
|
|
||||||
.get()
|
|
||||||
if (partnerAnswerSnap.exists) continue
|
|
||||||
|
|
||||||
const [partnerSnap, answererSnap] = await Promise.all([
|
|
||||||
db().collection('users').doc(partnerId).get(),
|
|
||||||
db().collection('users').doc(answererId).get(),
|
|
||||||
])
|
|
||||||
|
|
||||||
const partner = partnerSnap.data() as UserDoc | undefined
|
|
||||||
const answerer = answererSnap.data() as UserDoc | undefined
|
|
||||||
|
|
||||||
if (!partner?.fcmToken) continue
|
|
||||||
|
|
||||||
// Use a generic placeholder — we don't include partner names in push text
|
|
||||||
const answererName = answerer?.displayName ?? 'Someone'
|
|
||||||
await sendPartnerAnsweredNotification(partner.fcmToken, answererName)
|
|
||||||
console.log(`[listener] notified ${partnerId} that ${answererId} answered in couple ${coupleId}`)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[listener] error processing answer ${answerRef.path}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
console.error('[listener] answer snapshot error:', err)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return unsubscribe
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import rateLimit from 'express-rate-limit'
|
|
||||||
import { getRateLimit } from '../config/env'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Skip rate limiting for successful requests from localhost (development)
|
|
||||||
*/
|
|
||||||
const skipLocalhost = (req: any): boolean => {
|
|
||||||
return req.socket?.remoteAddress?.includes('127.0.0.1') ||
|
|
||||||
req.socket?.remoteAddress?.includes('::1') ||
|
|
||||||
req.ip?.includes('127.0.0.1') ||
|
|
||||||
req.ip?.includes('::1')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get rate limit values from env vars (with defaults)
|
|
||||||
const WEBHOOK_LIMIT = getRateLimit('RATE_LIMIT_WEBHOOK', 10)
|
|
||||||
const HEALTH_LIMIT = getRateLimit('RATE_LIMIT_HEALTH', 30)
|
|
||||||
const DEFAULT_LIMIT = getRateLimit('RATE_LIMIT_DEFAULT', 60)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Webhook limiter: 10 requests per minute per IP (configurable via RATE_LIMIT_WEBHOOK)
|
|
||||||
* RevenueCat retries are spaced minutes apart, so this is generous.
|
|
||||||
* Successful webhooks (HTTP 200) DO count toward the limit — a processed
|
|
||||||
* webhook should not give an attacker unlimited free requests.
|
|
||||||
*/
|
|
||||||
export const webhookLimiter = rateLimit({
|
|
||||||
windowMs: 60 * 1000, // 1 minute
|
|
||||||
max: WEBHOOK_LIMIT,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
keyGenerator: (req) => req.ip || req.socket?.remoteAddress || 'unknown',
|
|
||||||
skip: (req) => skipLocalhost(req),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Health limiter: 30 requests per minute per IP (configurable via RATE_LIMIT_HEALTH)
|
|
||||||
* Allows frequent health checks without abuse.
|
|
||||||
*/
|
|
||||||
export const healthLimiter = rateLimit({
|
|
||||||
windowMs: 60 * 1000, // 1 minute
|
|
||||||
max: HEALTH_LIMIT,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
keyGenerator: (req) => req.ip || req.socket?.remoteAddress || 'unknown',
|
|
||||||
skip: (req) => skipLocalhost(req),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default/API limiter: 60 requests per minute per IP (configurable via RATE_LIMIT_DEFAULT)
|
|
||||||
* For future authenticated routes.
|
|
||||||
*/
|
|
||||||
export const defaultLimiter = rateLimit({
|
|
||||||
windowMs: 60 * 1000, // 1 minute
|
|
||||||
max: DEFAULT_LIMIT,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
keyGenerator: (req) => req.ip || req.socket?.remoteAddress || 'unknown',
|
|
||||||
skip: (req) => skipLocalhost(req),
|
|
||||||
})
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { Router, Request, Response } from 'express'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router.get('/', (_req: Request, res: Response) => {
|
|
||||||
res.json({ status: 'ok', ts: new Date().toISOString() })
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import { Router, Request, Response } from 'express'
|
|
||||||
import { syncEntitlement } from '../services/entitlement'
|
|
||||||
import { RevenueCatEvent } from '../types'
|
|
||||||
import { getEnvValue } from '../config/env'
|
|
||||||
import * as crypto from 'crypto'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads a RevenueCat Ed25519 public key from its base64 form.
|
|
||||||
* RevenueCat typically distributes the raw 32-byte public key (base64).
|
|
||||||
* We also accept DER SPKI form for forward compatibility.
|
|
||||||
*/
|
|
||||||
function loadRevenueCatPublicKey(base64Key: string): crypto.KeyObject {
|
|
||||||
const keyBytes = Buffer.from(base64Key, 'base64')
|
|
||||||
|
|
||||||
if (keyBytes.length === 32) {
|
|
||||||
// Raw Ed25519 public key -> encode as JWK
|
|
||||||
return crypto.createPublicKey({
|
|
||||||
key: {
|
|
||||||
kty: 'OKP',
|
|
||||||
crv: 'Ed25519',
|
|
||||||
x: keyBytes.toString('base64url'),
|
|
||||||
},
|
|
||||||
format: 'jwk',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise assume DER-encoded SubjectPublicKeyInfo
|
|
||||||
return crypto.createPublicKey({
|
|
||||||
key: keyBytes,
|
|
||||||
format: 'der',
|
|
||||||
type: 'spki',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies RevenueCat webhook signature using Ed25519.
|
|
||||||
* RevenueCat signs the raw request body with their signing key.
|
|
||||||
* Returns false for invalid signature/header; throws only for misconfiguration
|
|
||||||
* so the caller can return a 500 (fail-closed).
|
|
||||||
*/
|
|
||||||
function verifyRevenueCatSignature(req: Request): boolean {
|
|
||||||
const signingKey = process.env.REVENUECAT_SIGNING_KEY
|
|
||||||
|
|
||||||
// If signing key is not configured, fail closed (not dev mode)
|
|
||||||
if (!signingKey || signingKey.trim() === '') {
|
|
||||||
console.error('[webhook] REVENUECAT_SIGNING_KEY not set — rejecting all requests (fail-closed)')
|
|
||||||
throw new Error('REVENUECAT_SIGNING_KEY must be set in production')
|
|
||||||
}
|
|
||||||
|
|
||||||
const signatureHeader = req.headers['x-revenuecat-signature']
|
|
||||||
if (!signatureHeader) {
|
|
||||||
console.error('[webhook] Missing X-RevenueCat-Signature header')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// The raw body should have been captured by middleware
|
|
||||||
const rawBody = (req as any).rawBody
|
|
||||||
if (!rawBody || !Buffer.isBuffer(rawBody)) {
|
|
||||||
console.error('[webhook] Raw body not available for signature verification')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const signature = Buffer.from(
|
|
||||||
Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader,
|
|
||||||
'base64'
|
|
||||||
)
|
|
||||||
const publicKey = loadRevenueCatPublicKey(signingKey)
|
|
||||||
|
|
||||||
// Ed25519 uses no digest algorithm in Node.js
|
|
||||||
return crypto.verify(null, rawBody, publicKey, signature)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[webhook] Signature verification failed:', err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies RevenueCat webhook secret using constant-time comparison.
|
|
||||||
* Kept as a fallback when signature verification is not configured.
|
|
||||||
* A missing secret causes verification to fail (fail-closed).
|
|
||||||
*/
|
|
||||||
function verifyRevenueCatSecret(req: Request): boolean {
|
|
||||||
try {
|
|
||||||
const secret = getEnvValue('REVENUECAT_WEBHOOK_SECRET')
|
|
||||||
const authHeader = req.headers['authorization']
|
|
||||||
const auth = (Array.isArray(authHeader) ? authHeader[0] : authHeader) ?? ''
|
|
||||||
|
|
||||||
// Missing secret = fail closed
|
|
||||||
if (!secret) {
|
|
||||||
console.error('[webhook] REVENUECAT_WEBHOOK_SECRET not set — rejecting all requests (fail-closed)')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
|
||||||
const authBuffer = Buffer.from(auth)
|
|
||||||
const secretBuffer = Buffer.from(secret)
|
|
||||||
|
|
||||||
if (authBuffer.length !== secretBuffer.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return crypto.timingSafeEqual(authBuffer, secretBuffer)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[webhook] Secret verification error:', err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post('/revenuecat', async (req: Request, res: Response) => {
|
|
||||||
const signatureKeyConfigured = !!process.env.REVENUECAT_SIGNING_KEY?.trim()
|
|
||||||
const secretConfigured = !!process.env.REVENUECAT_WEBHOOK_SECRET?.trim()
|
|
||||||
|
|
||||||
// Fail closed: at least one auth method must be configured
|
|
||||||
if (!signatureKeyConfigured && !secretConfigured) {
|
|
||||||
console.error('[webhook] No webhook auth configured — rejecting all requests (fail-closed)')
|
|
||||||
res.status(500).json({ error: 'webhook_auth_not_configured' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let verified = false
|
|
||||||
try {
|
|
||||||
verified = signatureKeyConfigured
|
|
||||||
? verifyRevenueCatSignature(req)
|
|
||||||
: verifyRevenueCatSecret(req)
|
|
||||||
} catch (err: any) {
|
|
||||||
// Configuration/internal errors are surfaced as 500 (fail-closed)
|
|
||||||
console.error('[webhook] Auth verification error:', err)
|
|
||||||
res.status(500).json({ error: 'auth_verification_failed', message: err.message })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verified) {
|
|
||||||
res.status(401).json({ error: 'unauthorized' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = req.body as RevenueCatEvent
|
|
||||||
if (!body?.event?.type || !body?.event?.app_user_id) {
|
|
||||||
res.status(400).json({ error: 'malformed payload' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acknowledge immediately — RC retries if we don't respond within 10s
|
|
||||||
res.status(200).json({ received: true })
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncEntitlement(body)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[webhook] entitlement sync failed:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { db } from '../config/firebase'
|
|
||||||
import { RevenueCatEvent } from '../types'
|
|
||||||
import { getEnvValue } from '../config/env'
|
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
|
|
||||||
// Product IDs that grant premium access (comma-separated, configurable via env)
|
|
||||||
const DEFAULT_PREMIUM_PRODUCT_IDS = 'closer_premium_monthly,closer_premium_yearly'
|
|
||||||
const PRODUCT_ID_ALLOWLIST = new Set(
|
|
||||||
getEnvValue('REVENUECAT_PREMIUM_PRODUCT_IDS')
|
|
||||||
.split(',')
|
|
||||||
.map(id => id.trim())
|
|
||||||
.filter(id => id.length > 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (PRODUCT_ID_ALLOWLIST.size === 0) {
|
|
||||||
console.warn('[entitlement] No product IDs in allowlist - entitlement validation will be bypassed')
|
|
||||||
}
|
|
||||||
|
|
||||||
const PREMIUM_ACTIVE_TYPES = new Set([
|
|
||||||
'INITIAL_PURCHASE',
|
|
||||||
'RENEWAL',
|
|
||||||
'PRODUCT_CHANGE',
|
|
||||||
'TRANSFER',
|
|
||||||
'UNCANCELLATION',
|
|
||||||
])
|
|
||||||
|
|
||||||
const PREMIUM_REVOKED_TYPES = new Set([
|
|
||||||
'EXPIRATION',
|
|
||||||
'CANCELLATION',
|
|
||||||
'SUBSCRIBER_ALIAS',
|
|
||||||
])
|
|
||||||
|
|
||||||
const PREMIUM_ENTITLEMENT_ID = 'closer_premium'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if premium is currently active for a user.
|
|
||||||
* Verifies both hasPremium flag AND expiration timestamp.
|
|
||||||
*/
|
|
||||||
export async function verifyPremiumActive(uid: string): Promise<boolean> {
|
|
||||||
const userDoc = await db().collection('users').doc(uid).get()
|
|
||||||
const data = userDoc.data()
|
|
||||||
if (!data) return false
|
|
||||||
|
|
||||||
// hasPremium must be true AND expiration must be in the future
|
|
||||||
if (!data.hasPremium) return false
|
|
||||||
|
|
||||||
const expiresAt = data.premiumExpiresAt as FirebaseFirestore.Timestamp | Date | undefined
|
|
||||||
if (!expiresAt) return false
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const expirationDate = typeof (expiresAt as any).toDate === 'function'
|
|
||||||
? (expiresAt as FirebaseFirestore.Timestamp).toDate()
|
|
||||||
: (expiresAt as Date)
|
|
||||||
return expirationDate > now
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncEntitlement(event: RevenueCatEvent): Promise<void> {
|
|
||||||
const { type, app_user_id: uid, id: eventId, product_id } = event.event
|
|
||||||
|
|
||||||
// Idempotency: atomically create the event record. If it already exists,
|
|
||||||
// another concurrent request processed it and we abort cleanly.
|
|
||||||
const eventRef = db().collection('entitlement_events').doc(eventId)
|
|
||||||
try {
|
|
||||||
await eventRef.create({ processedAt: new Date() })
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === 6 || err?.message?.includes('ALREADY_EXISTS')) {
|
|
||||||
console.log(`[entitlement] skipping duplicate event: ${eventId}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate product_id against allowlist
|
|
||||||
if (!PRODUCT_ID_ALLOWLIST.has(product_id)) {
|
|
||||||
console.log(`[entitlement] ignored event for unknown product_id: ${product_id}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify entitlement_id matches expected premium entitlement
|
|
||||||
const entitlementId = event.event.entitlement?.identifier || event.event.entitlement_id
|
|
||||||
if (entitlementId !== PREMIUM_ENTITLEMENT_ID) {
|
|
||||||
console.log(`[entitlement] ignored event for wrong entitlement: ${entitlementId} (expected: ${PREMIUM_ENTITLEMENT_ID})`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRef = db().collection('users').doc(uid)
|
|
||||||
|
|
||||||
if (PREMIUM_ACTIVE_TYPES.has(type)) {
|
|
||||||
const updates: { hasPremium: true; premiumExpiresAt?: FirebaseFirestore.Timestamp } = { hasPremium: true }
|
|
||||||
|
|
||||||
// Store expiration timestamp if provided
|
|
||||||
const expirationAtMs = event.event.expiration_at_ms
|
|
||||||
if (expirationAtMs !== undefined) {
|
|
||||||
updates.premiumExpiresAt = admin.firestore.Timestamp.fromMillis(expirationAtMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
await userRef.update(updates)
|
|
||||||
console.log(`[entitlement] hasPremium=true for ${uid} (${type})`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PREMIUM_REVOKED_TYPES.has(type)) {
|
|
||||||
const updates: { hasPremium: false; premiumExpiresAt: null } = { hasPremium: false, premiumExpiresAt: null }
|
|
||||||
|
|
||||||
await userRef.update(updates)
|
|
||||||
console.log(`[entitlement] hasPremium=false for ${uid} (${type})`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[entitlement] ignored event type: ${type}`)
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { messaging } from '../config/firebase'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a push notification to a user's device.
|
|
||||||
* Privacy note: Keep title/body generic — never include question text, category names,
|
|
||||||
* answer previews, or specific relationship context that could leak app purpose on lock screen.
|
|
||||||
*/
|
|
||||||
export async function sendPushToUser(
|
|
||||||
fcmToken: string,
|
|
||||||
title: string,
|
|
||||||
body: string,
|
|
||||||
data?: Record<string, string>
|
|
||||||
): Promise<void> {
|
|
||||||
await messaging().send({
|
|
||||||
token: fcmToken,
|
|
||||||
notification: { title, body },
|
|
||||||
data,
|
|
||||||
android: {
|
|
||||||
priority: 'high',
|
|
||||||
notification: { channelId: 'partner_activity' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notify a user that their partner has answered a question.
|
|
||||||
* Privacy: Use generic wording that doesn’t reveal question content, categories,
|
|
||||||
* answer previews, or even the partner's name on the lock screen.
|
|
||||||
*/
|
|
||||||
export async function sendPartnerAnsweredNotification(
|
|
||||||
partnerFcmToken: string,
|
|
||||||
_partnerName: string
|
|
||||||
): Promise<void> {
|
|
||||||
await sendPushToUser(
|
|
||||||
partnerFcmToken,
|
|
||||||
'You have something new from your partner',
|
|
||||||
'Tap to open Closer and see what they shared.',
|
|
||||||
{ type: 'partner_answered' }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remind a user to answer a question.
|
|
||||||
* Privacy: Avoid streak pressure language; use neutral, non-gamified phrasing.
|
|
||||||
*/
|
|
||||||
export async function sendStreakReminderNotification(
|
|
||||||
fcmToken: string
|
|
||||||
): Promise<void> {
|
|
||||||
await sendPushToUser(
|
|
||||||
fcmToken,
|
|
||||||
'You have something new in Closer',
|
|
||||||
'A moment is waiting for you. Tap to open and share your thoughts.',
|
|
||||||
{ type: 'streak_reminder' }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
export interface UserDoc {
|
|
||||||
uid: string
|
|
||||||
displayName: string
|
|
||||||
email?: string
|
|
||||||
coupleId?: string
|
|
||||||
fcmToken?: string
|
|
||||||
hasPremium: boolean
|
|
||||||
createdAt: FirebaseFirestore.Timestamp
|
|
||||||
notifPartnerAnswered?: boolean
|
|
||||||
notifChatMessage?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoupleDoc {
|
|
||||||
userIds: string[]
|
|
||||||
createdAt: FirebaseFirestore.Timestamp
|
|
||||||
streakCount: number
|
|
||||||
lastStreakAt?: FirebaseFirestore.Timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnswerDoc {
|
|
||||||
userId: string
|
|
||||||
answeredAt: FirebaseFirestore.Timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RevenueCatEvent {
|
|
||||||
event: {
|
|
||||||
id: string // Event ID for idempotency
|
|
||||||
type: string
|
|
||||||
app_user_id: string
|
|
||||||
product_id: string
|
|
||||||
period_type?: string
|
|
||||||
expiration_at_ms?: number
|
|
||||||
is_family_share?: boolean
|
|
||||||
entitlement?: {
|
|
||||||
identifier: string
|
|
||||||
product_id: string
|
|
||||||
}
|
|
||||||
entitlement_id?: string // Alternative field name
|
|
||||||
store?: 'app_store' | 'play_store' | 'mac_store' | 'stripe' | 'unknown'
|
|
||||||
environment?: 'SANDBOX' | 'PRODUCTION'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["ES2020"],
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue