Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
103 commits
Select commit Hold shift + click to select a range
424bf76
feat(matching): overhaul matching engine — age filtering, algorithm c…
GravityDarkLab Jun 17, 2026
e7322db
fix(api): remove stale algorithm arg from matching.job and audit arg …
GravityDarkLab Jun 17, 2026
03f7581
docs: update seeds and READMEs for matching overhaul
GravityDarkLab Jun 17, 2026
4ca21e5
docs(api): sync OpenAPI spec with matching overhaul
GravityDarkLab Jun 17, 2026
8aa1e87
feat(matching): add religion and long-distance hard filters
GravityDarkLab Jun 18, 2026
775ec0d
feat(api): bot protection — honeypot field, IP capture, tighter rate …
GravityDarkLab Jun 18, 2026
923c34b
feat(api): AI match summary — cached compatibility notes per pair
GravityDarkLab Jun 18, 2026
f166cf4
feat(admin): show submission IP in applicant detail
GravityDarkLab Jun 18, 2026
88f5346
feat(admin): remove deprecated cosine/baseline algorithm options
GravityDarkLab Jun 18, 2026
89a5ca9
feat(frontend): structured partner profile view with five sections
GravityDarkLab Jun 18, 2026
8bf1b36
feat(frontend): live word-count and encouragement on free-text form f…
GravityDarkLab Jun 18, 2026
be9405a
feat(i18n): add translation keys for all new features (en/fr/de/ar)
GravityDarkLab Jun 18, 2026
ec90ef0
test(api): add tests for honeypot rejection and match summary endpoint
GravityDarkLab Jun 18, 2026
bbab670
test(frontend): update Matching and MatchCard tests for overhaul
GravityDarkLab Jun 18, 2026
c45db47
docs: sync OpenAPI spec and seeds for matching overhaul
GravityDarkLab Jun 18, 2026
465a42a
refactor(api): remove dead filterCandidates export
GravityDarkLab Jun 18, 2026
45ba867
refactor(api): dedupe answer-normalization helper across filters
GravityDarkLab Jun 18, 2026
f1582f4
refactor(frontend): extract shared WordCountHint component
GravityDarkLab Jun 18, 2026
b65ce49
refactor(frontend): use shared Badge instead of local Chip
GravityDarkLab Jun 18, 2026
226efd2
refactor(api): extract shared getClientIp/getRequestMeta helper
GravityDarkLab Jun 18, 2026
6ec8bc5
perf(api): compute buildTexts once per applicant in getOrComputeEmbed…
GravityDarkLab Jun 18, 2026
d519525
refactor(frontend): extract shared API_BASE constant
GravityDarkLab Jun 18, 2026
0a96f02
refactor(frontend): extract useDebouncedValue/usePagedFilter hooks
GravityDarkLab Jun 18, 2026
f19e861
refactor(api): remove dead identityExistsById
GravityDarkLab Jun 19, 2026
3813ef7
fix(api): guard applicantId ObjectId cast and key summary cache by pr…
GravityDarkLab Jun 19, 2026
6561a5b
perf(api): parallelize recalcOrphanedStatuses
GravityDarkLab Jun 19, 2026
3baaf82
docs(api): note privacy implication of OPENAI_CHAT_MODEL with openai …
GravityDarkLab Jun 19, 2026
ff3547d
fix(api): fall back to socket address for client IP, safely
GravityDarkLab Jun 19, 2026
b22d8c5
fix(api): correct audit metadata and dedupe partner IDs in profile.se…
GravityDarkLab Jun 19, 2026
af4a8d7
docs(api): fix MatchSummary cache-invalidation docs to match real env…
GravityDarkLab Jun 19, 2026
4dc0161
fix(frontend): clamp invalid page query param to 1
GravityDarkLab Jun 19, 2026
6dd25e4
fix(frontend): stop rendering Instagram before mutual reveal at in_pr…
GravityDarkLab Jun 19, 2026
2f76972
test(api): move embedding/config tests under unit/matching and unit/c…
GravityDarkLab Jun 20, 2026
6239e58
chore: ignore brainstorming companion scratch directory
GravityDarkLab Jun 20, 2026
e6c600a
docs: add design spec for warm dating experience + name reveal
GravityDarkLab Jun 20, 2026
ecf5bd0
docs: add implementation plan for warm dating experience + name reveal
GravityDarkLab Jun 21, 2026
119a4a9
feat(api): add datingStartedAt and outcomeFeedback to MatchDoc
GravityDarkLab Jun 21, 2026
3f907c8
feat(api): add day-3/day-7 outcome gating helpers
GravityDarkLab Jun 21, 2026
a1b7e12
feat(api): expose datingStartedAt (and partnerFullName slot) on Appli…
GravityDarkLab Jun 21, 2026
6e5eb15
fix(api): widen CANCEL/OUTCOME_ELIGIBLE_DAYS to number to fix TS2367
GravityDarkLab Jun 21, 2026
0117f2d
feat(api): set datingStartedAt when a match becomes dating
GravityDarkLab Jun 21, 2026
e1f6917
feat(api): gate outcome reporting by day, accept feedback + continuat…
GravityDarkLab Jun 21, 2026
1e53341
refactor(api): reuse applyMatchStatusSideEffects for the break-contin…
GravityDarkLab Jun 21, 2026
2c7788a
feat(api): add distance-preference nudge and acknowledgement endpoint
GravityDarkLab Jun 21, 2026
beb0088
feat(frontend): add confetti-drift and heart-pulse keyframes
GravityDarkLab Jun 21, 2026
e5ec29b
feat(frontend): add warm check-in/outcome/distance-nudge copy, soften…
GravityDarkLab Jun 21, 2026
a7ed675
fix(frontend): correct German grammar in no_spark feedback tag
GravityDarkLab Jun 21, 2026
b1d5f79
feat(frontend): gate MatchCard's dating outcome UI by day, add feedba…
GravityDarkLab Jun 21, 2026
d0c8d76
feat(frontend): show distance-preference nudge card on the dashboard
GravityDarkLab Jun 21, 2026
1cfa0fc
fix(frontend): show error toast instead of silently dismissing on nud…
GravityDarkLab Jun 21, 2026
82200ad
feat(api): add first_name/last_name to questionnaire v1.2.0 and ident…
GravityDarkLab Jun 21, 2026
6c49098
feat(api): encrypt and reveal full name alongside the Instagram handle
GravityDarkLab Jun 21, 2026
6c46e38
feat(api,frontend): thread full name through every identity reveal ca…
GravityDarkLab Jun 21, 2026
0e9bd34
feat(frontend): collect first/last name on the application form
GravityDarkLab Jun 21, 2026
8ddb5e8
fix(frontend): give first/last name inputs even flex sizing on Step1I…
GravityDarkLab Jun 21, 2026
d820f8a
feat(frontend): show revealed full name in the admin identity card
GravityDarkLab Jun 21, 2026
a296a4b
feat(api): centralize literal constants, rename JWT_EXPIRY to ADMIN_J…
GravityDarkLab Jun 22, 2026
f05b6e3
feat(api): rebuild password suggestion generator on the real EFF word…
GravityDarkLab Jun 22, 2026
0d15340
test(api): add missing unit coverage for matching helpers and utils
GravityDarkLab Jun 22, 2026
9b3a880
docs(api): fix stale matching README and openapi descriptions
GravityDarkLab Jun 22, 2026
12d0f8d
refactor(api): simplify cors doc comment and index-creation helper
GravityDarkLab Jun 22, 2026
cfe8955
chore(frontend): update Jerusalem/Tel Aviv/Haifa city labels to Pales…
GravityDarkLab Jun 22, 2026
17f4f62
feat(api,frontend): show the applicant's own name in the portal, fix …
GravityDarkLab Jun 22, 2026
0030932
docs(api): document name reveal, outcome gating, and the nudge-ack en…
GravityDarkLab Jun 22, 2026
58f9400
docs: update READMEs for name reveal and the warm dating-outcome flow
GravityDarkLab Jun 22, 2026
c7bee16
fix: address Copilot review findings on PR #10
GravityDarkLab Jun 22, 2026
1510641
fix(api): alias renamed city labels in the long-distance filter
GravityDarkLab Jun 22, 2026
79a5c53
revert(api): drop the location-alias workaround, only Palestine going…
GravityDarkLab Jun 22, 2026
5d1d273
docs: design doc for LLM listwise-rerank matching score
GravityDarkLab Jun 28, 2026
3a0b351
feat(api): tune LLM prompts for cost, reliability, and skip unnecessa…
GravityDarkLab Jun 28, 2026
cd8f6f4
refactor(api): extract shared buildProfileSnippet helper for LLM prompts
GravityDarkLab Jun 28, 2026
5db5973
feat(api): add MatchRerankDoc model and match_reranks collection
GravityDarkLab Jun 28, 2026
a5e5937
feat(api): add LLM listwise-rerank service for match scoring
GravityDarkLab Jun 28, 2026
b965250
feat(api): rerank embedding shortlist with a listwise LLM call before…
GravityDarkLab Jun 28, 2026
0927fff
fix(api): add a timeout to the LLM chat completion fetch call
GravityDarkLab Jun 28, 2026
b7a3627
docs: implementation plan for LLM listwise-rerank matching score
GravityDarkLab Jun 28, 2026
e87c500
perf(api): cap concurrent rerank calls in a full matching pass
GravityDarkLab Jun 28, 2026
b7923fa
docs: document the LLM listwise-rerank stage in matching READMEs
GravityDarkLab Jun 28, 2026
480e90b
chore: keep planning docs (specs/plans) local-only, out of the repo
GravityDarkLab Jun 28, 2026
20331b5
docs: write up the LLM listwise-rerank matching score as a real, ship…
GravityDarkLab Jun 28, 2026
e640cc0
feat: add bun run eval:rerank — compare embedding vs LLM-reranked scores
GravityDarkLab Jun 28, 2026
8504bf1
docs: record first measured embedding-vs-LLM score distribution
GravityDarkLab Jun 28, 2026
ff95a45
feat: surface the LLM rerank reasoning to applicants as a match-card …
GravityDarkLab Jun 28, 2026
79bd46c
debug(api): log why a rerank/chat call falls back instead of failing …
GravityDarkLab Jun 28, 2026
ed8741e
feat(api): suppress reasoning-model chain-of-thought in the rerank call
GravityDarkLab Jun 28, 2026
01afe29
debug(api): log partial-response fallbacks, the actual silent path
GravityDarkLab Jun 28, 2026
e89d570
fix(api): tell the model exactly how many entries to return, verbatim…
GravityDarkLab Jun 28, 2026
0ae8e0a
fix(api): enforce rankings array length via JSON schema, not prompt text
GravityDarkLab Jun 28, 2026
28e6451
feat(api): decouple chat provider from embedding provider
GravityDarkLab Jun 28, 2026
293f0f5
fix(api): use max_completion_tokens for OpenAI, max_tokens for local
GravityDarkLab Jun 28, 2026
755479a
fix(api): omit temperature for OpenAI, the whole o-series/gpt-5.x lin…
GravityDarkLab Jun 28, 2026
e83da0f
fix(api): raise the chat-completion timeout, configurable per call
GravityDarkLab Jun 28, 2026
4305656
docs: record the second (clean) score measurement on a hosted model
GravityDarkLab Jun 28, 2026
4f1c9db
refactor(api): extract generateChatCompletion's branching into pure f…
GravityDarkLab Jun 28, 2026
bc48d63
fix(api): apply the reasoning-model hardening to match-summary/icebre…
GravityDarkLab Jun 28, 2026
83a6584
fix(api): add a timeout to the embedding API call, same gap as chat had
GravityDarkLab Jun 28, 2026
3948de5
fix(api): enforce directional age vetoes even when max_age_gap is blank
GravityDarkLab Jul 9, 2026
9aed2de
fix(api): restrict outcome reporting — success requires dating, in_pr…
GravityDarkLab Jul 9, 2026
7e4c548
fix(api): trust X-Forwarded-For only behind a declared proxy (TRUST_P…
GravityDarkLab Jul 9, 2026
d12653b
fix(api): fence untrusted applicant text in LLM prompts against instr…
GravityDarkLab Jul 9, 2026
59a3831
fix(api): scope the take-a-break outcome to the reporter only
GravityDarkLab Jul 9, 2026
a95caaf
feat(frontend): confirm before reporting a successful outcome
GravityDarkLab Jul 9, 2026
3d8eca3
chore: sweep review hygiene — stale comments and RTL-unsafe classes
GravityDarkLab Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ FORM_SECRET=replace_with_64_hex_chars_generated_by_openssl_rand_hex_32
JWT_SECRET=replace_with_long_random_secret
# Format used for time span should be a number followed by a unit, such as "5 minutes" or "1 day".
# Valid units are: "sec", "secs", "second", "seconds", "s", "minute", "minutes", "min", "mins", "m", "hour", "hours", "hr", "hrs", "h", "day", "days", "d", "week", "weeks", "w", "year", "years", "yr", "yrs", and "y".
JWT_EXPIRY=8h
ADMIN_JWT_EXPIRY=8h

# ─── Server ───────────────────────────────────────────────────────────────────
PORT=3001
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ Thumbs.db

# Claude
.claude/

.superpowers/

# Local-only planning artifacts (design specs, implementation plans) —
# useful while working, not meant to live in the repo permanently
docs/superpowers/
37 changes: 20 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
A privacy-first couple matching platform. Applicants fill out a form, an admin reviews and runs the matching engine, and compatible pairs are surfaced — no Instagram handles or personal details ever leave the encrypted identity store.

**Stack:** Bun · Hono · MongoDB · React · Vite · Tailwind
**Docs:** [`api/README.md`](./api/README.md) · [`frontend/README.md`](./frontend/README.md) · [`api/src/matching/README.md`](./api/src/matching/README.md)
**Docs:** [`api/README.md`](./api/README.md) · [`frontend/README.md`](./frontend/README.md) · [`api/src/matching/README.md`](./api/src/matching/README.md) · [LLM listwise-rerank matching score — design & literature](./docs/llm-listwise-rerank-matching-score.md)

---

Expand Down Expand Up @@ -115,7 +115,7 @@ ons/
│ │ ├── controllers/ ← Request handlers
│ │ ├── routes/ ← Hono route definitions
│ │ ├── middleware/ ← Auth, rate limiting, audit logging
│ │ └── __tests__/ ← Full test suite (199 tests)
│ │ └── __tests__/ ← Full test suite (515 tests)
│ ├── docs/openapi.yaml ← Full OpenAPI 3.1 spec
│ ├── Dockerfile
│ └── README.md ← API setup & reference
Expand All @@ -138,11 +138,12 @@ ons/
## How it works

1. **Applicant fills the form** — the frontend fetches the active questionnaire, gates access with an invite key, and POSTs answers to the API.
2. **PII is isolated at submission** — the Instagram handle is AES-256-GCM encrypted and stored in a separate `identities` collection, never in the applicant profile.
3. **Admin runs matching** — `POST /api/v1/matching/run` scores all active applicants pairwise and returns ranked candidates per person.
4. **Admin resolves identities** — every access to an encrypted identity is audit-logged.
2. **PII is isolated at submission** — the first/last name and Instagram handle are each AES-256-GCM encrypted (own fresh IV per field) and stored in a separate `identities` collection, never in the applicant profile.
3. **Admin runs matching** — `POST /api/v1/matching/run` shortlists all active applicants pairwise with text embeddings, then an LLM rerank call (one per applicant, covering its whole shortlist) judges the shortlist and produces the score that's actually returned — the embedding step alone is structurally incapable of scoring a great pair above ~80%, see [`api/src/matching/README.md`](./api/src/matching/README.md#llm-rerank-servicesmatch-rerankservicets).
4. **Admin resolves identities** — every access to someone else's encrypted identity is audit-logged. (Applicants can always see their own name on their own profile — that's not a "reveal", just their own data.)
5. **Applicant uses their portal** — a magic link from the admin grants access to `/profile`, where the applicant reviews their matches and can edit their questionnaire answers.
6. **Matched applicants connect** — either side can request to exchange Instagram handles; once both accept, identities are decrypted (audit-logged) and shared with both parties.
6. **Matched applicants connect** — either side can initiate contact; the target receives ice-breaker prompts and date ideas to decide. Only when the target **accepts** are both parties' Instagram handles and names decrypted simultaneously, audit-logged, and revealed to each other. A declined or withdrawn request leaves identities sealed.
7. **Dating, the warm way** — once mutual, outcome reporting is time-gated rather than available immediately: a friendly rotating check-in message shows for the first 3 days, a quiet "things aren't working out?" option unlocks at day 3, and full "it worked / it didn't" reporting unlocks at day 7. A failed outcome can optionally tag why (e.g. "too far apart") and choose to keep looking or take a break — tagging distance later surfaces a one-time, dismissible suggestion to open up to long-distance matches.

---

Expand All @@ -157,16 +158,17 @@ browser ──► frontend (React + Vite)
│form│ admin │ profile │ match │
└──┬─┴───────┴────┬────┴───┬───┘
│ │ │
MongoDB Matching engine
┌────┴──────┐ (baseline / cosine /
│ applicants│ embedding-cosine)
│ identities│
│ embeddings│
│ audit_logs│
└───────────┘
MongoDB Matching engine
┌───────┴───────┐ (embedding-cosine shortlist
│ applicants │ + age filter, then an
│ identities │ LLM listwise rerank for
│ embeddings │ the displayed score)
│ match_reranks │
│ audit_logs │
└───────────────┘
```

See [`api/src/matching/README.md`](./api/src/matching/README.md) for the full algorithm breakdown — weights, math, embedding batching, and how to add a new algorithm.
See [`api/src/matching/README.md`](./api/src/matching/README.md) for the full pipeline breakdown — weights, age filter math, embedding batching, and how to extend the system.

---

Expand All @@ -175,11 +177,11 @@ See [`api/src/matching/README.md`](./api/src/matching/README.md) for the full al
| Data | Collection | Who can access |
|---|---|---|
| Questionnaire answers | `applicants` | Admin, and the applicant themselves via `/profile` |
| Instagram handle | `identities` (AES-256-GCM encrypted) | Admin (audit-logged), or a matched applicant after mutual reveal (audit-logged) |
| Instagram handle + first/last name | `identities` (AES-256-GCM encrypted, each field its own IV) | The applicant themselves (their own name, not audit-logged); admin (audit-logged); or both matched applicants simultaneously after mutual acceptance of a contact request (each audit-logged independently) |
| Text embeddings | `embeddings` | Matching engine |
| Admin actions | `audit_logs` | Admin |

The Instagram handle never touches the `applicants` collection. It is encrypted with a fresh random IV on every write and stored separately. Decryption requires the `ENCRYPTION_KEY` secret and is always written to `audit_logs` before the plaintext is returned — whether the reader is an admin or a matched applicant who completed a mutual identity reveal.
The Instagram handle and name never touch the `applicants` collection. Each is encrypted with its own fresh random IV on every write and stored separately — even within the same `identities` document, no two fields ever share an IV. Decryption requires the `ENCRYPTION_KEY` secret. Decrypting *someone else's* identity (an admin lookup, or a matched applicant's mutual reveal) is always written to `audit_logs` before the plaintext is returned; an applicant viewing their own name on their own profile is not, since that's not a privacy-sensitive reveal.

---

Expand Down Expand Up @@ -330,7 +332,8 @@ Inject secrets via ECS task-definition environment variables or AWS Secrets Mana
| `bun run dev:api:prod` | API only — `.env.prod` (hot reload) |
| `bun run dev:frontend` | Frontend only (hot reload) |
| `bun run seed` | Interactive seed runner — choose questionnaire, applicants, or both; prompts for environment |
| `bun run eval:rerank` | Runs a full matching pass and prints embedding-vs-LLM score distributions side by side (real calls, not mocked) |
| `bun run build` | Build all workspaces |
| `bun run typecheck` | Type-check all workspaces |
| `bun run test` | Run API + frontend test suites in parallel (API: 383 tests, no DB required) |
| `bun run test` | Run API + frontend test suites in parallel (API: 515 tests, no DB required) |
| `bun run test:smoke` | Run smoke tests against a live server + DB (requires env vars — see `tests/smoke/`) |
32 changes: 28 additions & 4 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ FORM_SECRET=replace_with_64_hex_chars_generated_by_openssl_rand_hex_32
JWT_SECRET=replace_with_long_random_secret
# Format used for time span should be a number followed by a unit, such as "5 minutes" or "1 day".
# Valid units are: "sec", "secs", "second", "seconds", "s", "minute", "minutes", "min", "mins", "m", "hour", "hours", "hr", "hrs", "h", "day", "days", "d", "week", "weeks", "w", "year", "years", "yr", "yrs", and "y".
JWT_EXPIRY=8h
ADMIN_JWT_EXPIRY=8h
# Applicant portal session length — separate from ADMIN_JWT_EXPIRY above since
# admins and applicants have very different re-login tolerance. Same format.
APPLICANT_JWT_EXPIRY=30d

# ─── Account deletion ──────────────────────────────────────────────────────────
# Days an inactive applicant's personal data is retained before permanent purge.
Expand All @@ -41,6 +44,11 @@ DELETION_GRACE_DAYS=180
# ─── Server ───────────────────────────────────────────────────────────────────
PORT=3001
NODE_ENV=development
# Set to true ONLY when a trusted reverse proxy (nginx/caddy/load balancer that
# overwrites X-Forwarded-For) sits in front of the API. When false (default),
# proxy headers are ignored for the client IP used in rate limiting and audit
# logs — otherwise any direct client could spoof its IP per request.
TRUST_PROXY=false
# Full base URL logged on startup. Leave empty in dev (defaults to http://localhost:<PORT>).
# Set in test/prod: PUBLIC_URL=https://api.domain.com
PUBLIC_URL=
Expand All @@ -65,10 +73,26 @@ EMBEDDING_MODEL=
OPENAI_API_KEY=
EMBEDDING_BASE_URL=

# ─── AI chat (ice-breaker generation) ─────────────────────────────────────────
# Uses the same provider/endpoint as embeddings. Only the model name differs.
# openai provider: OPENAI_CHAT_MODEL=gpt-4o-mini
# ─── AI chat (ice-breaker generation, match summaries, match rerank) ──────────
# Defaults to EMBEDDING_PROVIDER/EMBEDDING_BASE_URL above if left unset — set
# these only if you want chat completions to use a *different* backend than
# embeddings (e.g. local embeddings you've already cached + a hosted OpenAI
# chat model, or vice versa).
#
# CHAT_PROVIDER=openai → OPENAI_API_KEY (same key as above) is required
# CHAT_PROVIDER=local → CHAT_BASE_URL (or EMBEDDING_BASE_URL as a fallback)
# CHAT_BASE_URL=http://localhost:1234/v1 # LM Studio
# CHAT_BASE_URL=http://localhost:11434/v1 # Ollama
#
# openai provider: OPENAI_CHAT_MODEL=gpt-5.4-mini
# local provider: OPENAI_CHAT_MODEL=llama-3.2-3b-instruct (whatever is loaded)
# Privacy note: with CHAT_PROVIDER=openai (or EMBEDDING_PROVIDER=openai when
# CHAT_PROVIDER is unset), free-text profile answers (lifestyle, deal
# breakers, etc. — never the Instagram handle) are sent to OpenAI to generate
# ice-breakers, match summaries, and rerank scores. Use a local provider to
# keep all profile text on infrastructure you control.
CHAT_PROVIDER=
CHAT_BASE_URL=
OPENAI_CHAT_MODEL=gpt-4o-mini

# ─── Scheduled matching job ────────────────────────────────────────────────────
Expand Down
43 changes: 32 additions & 11 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,14 @@ cp api/.env.example api/.env
| `PORT` | `3001` | Server port |
| `NODE_ENV` | `development` | Environment |
| `ALLOWED_ORIGINS` | `http://localhost:3000,http://localhost:5173` | CORS origins |
| `JWT_EXPIRY` | `8h` | JWT token lifetime |
| `TRUST_PROXY` | `false` | Set `true` only behind a trusted reverse proxy that overwrites `X-Forwarded-For` — enables proxy headers as the client IP for rate limiting and audit logs; otherwise headers are ignored (spoofable) |
| `ADMIN_JWT_EXPIRY` | `8h` | Admin session JWT lifetime |
| `APPLICANT_JWT_EXPIRY` | `30d` | Applicant portal session JWT lifetime |
| `PUBLIC_URL` | _(empty)_ | Base URL for startup logs |
| `OPENAI_API_KEY` | _(empty)_ | Required when `EMBEDDING_PROVIDER=openai` |
| `OPENAI_API_KEY` | _(empty)_ | Required when `EMBEDDING_PROVIDER=openai` or `CHAT_PROVIDER=openai` |
| `CHAT_PROVIDER` | `EMBEDDING_PROVIDER` | `openai` or `local` for ice-breakers/match-summaries/match-rerank — independent of the embedding provider |
| `CHAT_BASE_URL` | `EMBEDDING_BASE_URL` | Base URL for a local chat provider, if different from the local embedding server |
| `OPENAI_CHAT_MODEL` | `gpt-4o-mini` | Chat model name (e.g. `gpt-5.4-mini` for OpenAI) |

---

Expand Down Expand Up @@ -78,6 +83,20 @@ bun run --cwd .. seed:applicants -- --count 50 --clear

---

## Evaluating the matching score

`bun run eval:rerank` (from the monorepo root) runs one full matching pass and prints embedding-vs-LLM score distributions side by side — no need to disable the rerank stage to compare, since every candidate already carries both numbers. Requires a seeded applicant pool and a configured `EMBEDDING_PROVIDER`/`OPENAI_CHAT_MODEL` (real embedding + LLM calls, not mocked):

```bash
bun run eval:rerank # api/.env.dev
bun run eval:rerank --env=test
bun run eval:rerank --csv=out.csv # also write every candidate row to CSV
```

See ["7. Evaluation"](../docs/llm-listwise-rerank-matching-score.md#7-evaluation) in the design writeup for what this is meant to validate.

---

## API reference

### Public
Expand Down Expand Up @@ -107,7 +126,7 @@ curl -X POST http://localhost:3001/api/v1/admin/login \
| `GET` | `/api/v1/admin/me` | Current admin session info |
| `GET` | `/api/v1/admin/applicants` | List applicants (paginated, filterable) |
| `GET` | `/api/v1/admin/applicants/:id` | Get applicant profile |
| `GET` | `/api/v1/admin/applicants/:id/identity` | Decrypt & return Instagram handle ⚠ audit logged, `super_admin` only |
| `GET` | `/api/v1/admin/applicants/:id/identity` | Decrypt & return Instagram handle + full name ⚠ audit logged, `super_admin` only |
| `DELETE` | `/api/v1/admin/applicants/:id` | Deactivate applicant |
| `POST` | `/api/v1/admin/applicants/:id/regenerate-magic-link` | Issue a new portal magic link, `super_admin` only |
| `GET` | `/api/v1/admin/audit-logs` | View audit trail |
Expand All @@ -124,8 +143,8 @@ curl -X POST http://localhost:3001/api/v1/admin/login \
| `GET` | `/api/v1/matching/last-run` | Summary of the most recent matching pass |
| `POST` | `/api/v1/matching/run` | Full pairwise pass over all active applicants |

Both `candidates` and `run` accept an `algorithm` parameter: `baseline`, `cosine`, or `embedding-cosine`.
See [`src/matching/README.md`](./src/matching/README.md) for algorithm details.
Candidates are shortlisted with the `embedding-cosine` algorithm (semantic text embeddings + age filter), then the shortlist is rescored by an LLM listwise rerank call — that's the score actually returned and displayed. No `algorithm` parameter is accepted.
See [`src/matching/README.md`](./src/matching/README.md) for pipeline details.

### Applicant portal (session cookie)

Expand All @@ -141,10 +160,11 @@ Applicants log in with a magic link (issued by an admin) and, on first login, se
| `GET` | `/api/v1/profile/answers` | Get my questionnaire answers |
| `PUT` | `/api/v1/profile/answers` | Edit my questionnaire answers |
| `GET` | `/api/v1/profile/matches` | List my matches with score breakdown |
| `POST` | `/api/v1/profile/matches/:id/contact` | Request to exchange Instagram handles with a match ⚠ audit logged on acceptance |
| `POST` | `/api/v1/profile/matches/:id/respond` | Accept or decline a contact request |
| `POST` | `/api/v1/profile/matches/:id/contact` | Initiate contact with a match — returns ice-breakers and date ideas; no identity revealed yet |
| `POST` | `/api/v1/profile/matches/:id/respond` | Accept or decline a contact request — accepting reveals the initiator's handle + name immediately in the response |
| `POST` | `/api/v1/profile/matches/:id/withdraw` | Withdraw a contact request |
| `POST` | `/api/v1/profile/matches/:id/outcome` | Report a match outcome (`success` / `failed`) |
| `POST` | `/api/v1/profile/matches/:id/outcome` | Report a match outcome (`success` / `failed`). Time-gated: `failed` unlocks 3 days after `dating`, `success` after 7. Optional `outcomeFeedback` (tags + note) and a `continuation` (`continue`/`break`) choice on `failed` |
| `POST` | `/api/v1/profile/matches/:id/nudge-ack` | Dismiss the distance-preference nudge surfaced after a `failed` outcome tagged `too_far`; optionally opens the applicant to long-distance matches |
| `POST` | `/api/v1/profile/change-password` | Change my password |
| `POST` | `/api/v1/profile/deactivate` | Deactivate my account |
| `POST` | `/api/v1/profile/cancel-deletion` | Cancel a pending account deletion |
Expand Down Expand Up @@ -179,10 +199,11 @@ api/

## Privacy & security

- **No PII in applicant profiles** — Instagram handles are AES-256-GCM encrypted in a separate `identities` collection.
- **No PII in applicant profiles** — Instagram handles and first/last names are AES-256-GCM encrypted in a separate `identities` collection, each field with its own fresh IV (never reusing another field's IV, even within the same document).
- **Submission keys** — HMAC-SHA256(version, `FORM_SECRET`) prevents questionnaire version enumeration.
- **Audit logs** — every identity decryption (admin lookup *or* mutual match reveal) is written to `audit_logs` with actor, IP, user-agent, and timestamp before plaintext is returned.
- **Mutual identity reveal** — an applicant calling `/profile/matches/:id/contact` immediately decrypts and is shown the partner's Instagram handle; the partner sees the initiator's handle as soon as they view that match (`GET /profile/matches`), before accepting or declining.
- **Audit logs** — every identity decryption of *someone else's* data (admin lookup or mutual match reveal) is written to `audit_logs` with actor, IP, user-agent, and timestamp before plaintext is returned. Viewing your own name on your own profile is not audit-logged — it isn't a privacy-sensitive reveal.
- **Mutual identity reveal** — Instagram handles and full names are only decrypted when the target explicitly accepts a contact request (`POST /profile/matches/:id/respond` with `accept: true`). At that point both parties' handles/names are decrypted simultaneously, each reveal is audit-logged independently, and both parties see them on their next `GET /profile/matches` call (the response to `/respond` itself already includes the initiator's handle/name for the responding applicant). A declined or withdrawn request leaves identities sealed.
- **Outcome gating** — once a match is `dating`, `POST /profile/matches/:id/outcome` enforces a minimum wait: 3 days before `failed` can be reported, 7 before `success` — encourages giving a match a real chance before either applicant can end it.
- **Rate limiting** — in-memory sliding-window limiter on all public, admin, and applicant-portal routes.
- **Orientation filter** — incompatible pairs are excluded *before* scoring, never just ranked low.
- **Account deletion** — applicants can deactivate immediately or schedule a deletion with a cancellable grace period.
Loading
Loading