Skip to content

Project ons initialisation#1

Merged
GravityDarkLab merged 69 commits into
mainfrom
claude/suspicious-elion-ef091f
Jun 1, 2026
Merged

Project ons initialisation#1
GravityDarkLab merged 69 commits into
mainfrom
claude/suspicious-elion-ef091f

Conversation

@GravityDarkLab

Copy link
Copy Markdown
Owner

No description provided.

Achraf Labidi and others added 30 commits May 15, 2026 21:28
Full backend for a couple matching platform built with Bun, Hono, and
MongoDB. The central design principle is that sensitive personal data
(Instagram handles) is never stored in the main applicant profile — it
lives encrypted in a separate collection, resolvable only by admins.

Key decisions:
- Dynamic questionnaire schema: questions drive all form logic, matching
  dimensions, and sensitive-field routing via a `sensitive` flag on each
  question. No question IDs hardcoded in business logic.
- Privacy split: form.service.ts reads the active questionnaire and
  automatically routes `sensitive: true` answers to the `identities`
  collection (AES-256-GCM) and everything else to `applicants`.
- Human-friendly aliases: 36×36 Adjective+Noun pool ("Blue Falcon",
  "Silent River") with collision-safe retry. Applicants are identified
  by alias throughout; real handles never appear outside the identities
  collection.
- Plugin-based matching engine: Algorithm interface allows swapping
  between deterministic or AI-based scorers without changing the engine.
  Baseline uses 6 weighted dimensions (relationship type, deal-breakers,
  religion, affection proximity, long-distance, lifestyle).
- Audit logging: every admin identity resolution is logged with IP,
  user-agent, action, and target alias.
- In-memory sliding-window rate limiter: 100 req/10 min on form submit,
  20 req/min on admin routes.
- Strict env validation: server refuses to start if any required var
  (MONGODB_URI, ENCRYPTION_KEY, JWT_SECRET, ADMIN_*) is missing or
  malformed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Minimal, polished React + Tailwind frontend for the matching platform.
Structured as a self-contained app inside the monorepo — backend stays
at root, frontend lives in frontend/.

Pages:
- / — landing page with hero, CTA, and "how it works" cards
- /apply — 5-step multi-step form with per-step validation
- /success — alias reveal page ("Blue Falcon", "Silent River", etc.)

UI components (all hand-rolled, no component library):
- RadioCard — card-style radio group with accent selection state
- Toggle — animated yes/no switch
- Slider — 1–10 affection scale with live counter and tick marks
- Input / Textarea / Button — consistent with the design system
- ProgressBar — pill-style step indicator

Design system: warm off-white bg (#FAF9F7), terracotta accent (#C96442),
Inter font, all tokens in tailwind.config.js. Fully responsive, optimised
for 390px mobile viewports.

Form uses a single useForm at Apply.tsx level; each step exports a FIELDS
constant so trigger(FIELDS) validates only the current step before advancing.
API call hits VITE_API_URL/api/v1/form/submit on submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full-screen gate wraps the entire app. Users must enter VITE_INVITE_KEY
before any page is shown. Unlocked state persists in localStorage so
they don't re-enter on subsequent visits.

UX details:
- Unlock button disabled until input is non-empty
- 400ms deliberate delay on submit (feels intentional, not instant)
- Wrong key: shake animation + red border + inline error, input cleared
- Correct key: gate unmounts, landing page appears immediately
- Key input: centered, letter-spaced, monospace feel

Config: set VITE_INVITE_KEY in frontend/.env.local
  Generate with: openssl rand -hex 8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All backend files (src/, package.json, tsconfig.json, bun.lock,
.env.example, .gitignore) moved to api/ using git mv — history
is fully preserved. node_modules moved physically (not tracked).

Monorepo layout is now:
  api/       ← Bun + Hono backend
  frontend/  ← Vite + React frontend

tsc --noEmit passes clean from api/ after the move.
Root README updated to reflect the new structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root orchestration:
- package.json — Bun workspaces (api + frontend), central scripts
  - `bun run dev`          → both services in parallel (coloured output)
  - `bun run dev:api`      → api only
  - `bun run dev:frontend` → frontend only
  - `bun run seed`         → questionnaire seed
  - `bun run build / typecheck` → all workspaces

scripts/:
- dev.ts  — parallel runner that prefixes each service's output with
            a coloured [api] / [frontend] label, handles SIGINT/SIGTERM
- seed.ts — thin wrapper that spawns the api seed in its own cwd

Docker:
- api/Dockerfile         — two-stage (deps → runner), oven/bun:1.2-alpine
- frontend/Dockerfile    — two-stage (bun build → nginx:alpine), accepts
                           VITE_* build args baked in at build time
- frontend/nginx.conf    — SPA fallback, long-cache for hashed assets,
                           gzip, security headers
- docker-compose.yml     — production (builds images, mongo healthcheck,
                           VITE_INVITE_KEY required via :? syntax)
- docker-compose.dev.yml — dev (oven/bun image, source volume mounts,
                           isolated node_modules volumes, hot reload)

.gitignore + per-image .dockerignore added.
README rewritten to document all entry points.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
scripts/ — removed 'path' import (not available without @types/node):
  - Use `new URL('../api', import.meta.url).pathname` instead of resolve()
  - pipeWithLabel out param typed as NodeJS.WriteStream (accepts stdout+stderr)

Root tsconfig.json added — covers scripts/ with Bun types:
  - types: ["bun"]  → @types/bun installed as root devDependency
  - moduleResolution: bundler (matches Bun's resolver)
  - bun run typecheck now checks scripts/ + both workspaces

frontend/tsconfig.json — added "types": ["vite/client"]:
  - Fixes import.meta.env missing on ImportMeta (used in client.ts, InviteGate)

.claude — removed redundant frontend/.claude/ (root .claude/launch.json
  already has cwd: "frontend", so the subfolder was a duplicate)

All three typecheck targets now exit 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds GET /api/v1/docs (Swagger UI) and GET /api/v1/openapi.json in dev/test
environments. Uses @hono/swagger-ui and js-yaml to parse and serve the spec.
Extracts API_PREFIX_V1 constant to keep route registration DRY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
extractAuditContext now accepts the full Hono Context instead of a raw
Request. Falls back to getConnInfo(c).remote.address (Bun socket) after
proxy headers, so audit logs always record a real IP instead of "unknown"
when no reverse proxy is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Aliases are now randomly either "Adjective Noun" or "Noun Adjective",
growing the unique combination pool from 1 296 to 2 592.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clients must include an X-Submission-Key header when submitting a form.
The key is an HMAC-SHA256(version, FORM_SECRET) returned by GET /questionnaire,
proving the client fetched the version legitimately rather than guessing semver
strings. Verified with timingSafeEqual to prevent timing attacks.

GET /questionnaire now also accepts ?filter=active|all so admins can list
all questionnaire versions without triggering the submission-key flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /api/v1/admin/questionnaires creates a new questionnaire version and
atomically deactivates all existing active ones. Includes full Zod validation
of sections/questions, a CREATE_QUESTIONNAIRE audit log entry, and a
getAllQuestionnaires() service method for the ?filter=all listing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the baseline's hand-tuned rules with geometric cosine similarity
over encoded feature vectors. Four weighted components: numeric compatibility
(25%), lifestyle text similarity (20%), bidirectional character cross-match
(35%), and deal-breaker penalty (20%). Text fields use a shared bag-of-words
vocabulary; semantic synonyms ("driven" vs "ambitious") are not yet captured —
see embedding-cosine for that.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
embedding-cosine replaces bag-of-words text matching with dense vector
embeddings so semantically similar words ("driven" ≈ "ambitious") score
high. Supports OpenAI and any OpenAI-compatible local provider (LM Studio,
Ollama) via a single OpenAICompatibleProvider.

Embeddings are computed once at form submission (fire-and-forget) and
persisted to a new `embeddings` MongoDB collection. The prepare() step
loads vectors from the DB — zero API calls in steady state. Stale
embeddings (model changed) are re-computed and overwritten automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds filters.ts with isOrientationCompatible() — Straight matches only
opposite binary gender, Gay/Lesbian match same gender, Bi/Pan/Asexual have
no restriction. The engine applies filterCandidates() before scoring in
both getCandidates() and runFullMatchingPass(), so incompatible pairs are
never ranked regardless of algorithm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds seeds/applicants.seed.ts with realistic fake data pools, --count and
--clear flags, and a production safety guard. Minor improvements: getDb()
JSDoc, closeDb() warn when no client exists, configurable JWT expiry via
env, rate limiter and encryption inline comments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
build.yml      — runs `tsc --noEmit` on every push/PR using Bun.
secret-scan.yml — runs Gitleaks with full git history on every push/PR
                  to catch leaked credentials, API keys, and tokens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
build.yml: install from monorepo root (bun.lock lives there, not api/),
and use --frozen flag instead of --frozen-lockfile (the latter is for the
old binary bun.lockb format).

secret-scan.yml: set GITLEAKS_SCAN_MODE=detect to scan the entire working
tree instead of only the commits in the current push delta.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
"types": ["bun-types"] looked for a non-existent @types/bun-types package.
@types/bun is the actual package, referenced via "types": ["bun"] (TypeScript
strips the @types/ prefix). Added typeRoots to cover both api/node_modules
and the monorepo root node_modules so tsc finds hoisted packages in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…abot

ci.yml (replaces build.yml)
  - Type-check with Bun install cache (hashFiles bun.lock)
  - Docker build validation on every push/PR (layer cache via GHA)
  - Concurrency group — cancels stale runs for same branch

security.yml (replaces secret-scan.yml)
  - Gitleaks full working-tree scan (detect mode)
  - CodeQL SAST with security-extended query suite
  - Trivy filesystem scan (HIGH+CRITICAL CVEs) → SARIF → Security tab
  - Weekly schedule at 06:00 UTC to catch newly-disclosed CVEs

docker-publish.yml (new)
  - Triggers on main branch pushes and v* tags
  - Pushes to ghcr.io with branch/semver/sha tags
  - SLSA provenance attestation
  - Trivy image scan after push → SARIF → Security tab

dependabot.yml (new)
  - Weekly updates for npm, Docker base images, and GitHub Actions
  - Groups all npm deps into a single PR

api/Dockerfile + docker-compose.yml
  - Fix build context: monorepo root (bun.lock lives at root, not api/)
  - Fix --frozen-lockfile → --frozen (correct flag for text bun.lock)
  - Dockerfile now copies api/ subtree from root context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
api/Dockerfile: install deps directly from api/package.json instead of
using workspace mode. The root package.json declares a "frontend" workspace
that is absent in the build context, causing bun install to abort.
Simplified to a plain --production install with no lockfile flag needed.

ci.yml: correct build context to ./api (matches the new Dockerfile).

security.yml: trivy-action@0.30.0 does not exist — changed to @master.

docker-publish.yml: removed for now (docker push not yet needed).

docker-compose.yml: reverted context to ./api to match simplified Dockerfile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GravityDarkLab and others added 13 commits May 31, 2026 22:36
…itives

CodeQL (api/src/seeds/applicants.seed.ts):
- Replace all Math.random() calls with crypto.getRandomValues()-based
  randomFloat() helper. CodeQL flags Math.random() as insecure when
  values feed into data that gets stored/encrypted. Using the CSPRNG
  is correct even for seed data and eliminates the finding cleanly
  without any suppression comment.

GitGuardian (docker-compose-mongo-dev.yml):
- Add # gitguardian:ignore inline comments on MONGO_APP_PASSWORD and
  ME_CONFIG_BASICAUTH_PASSWORD default values. These are intentional
  local-dev defaults (always overridable via --env-file) and not real
  credentials — but GitGuardian's generic-password rule fires on them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The bun.lock was generated with Bun 1.3.x but the Dockerfiles referenced
oven/bun:1.2-alpine (1.2.23), causing `bun install --frozen` to fail with
"lockfile had changes" on every CI Docker build job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ample env

Test setup values are non-secret placeholders; added gitguardian:ignore
annotations. Example env file passwords replaced with CHANGE_ME to avoid
triggering secret detection on documented defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ile drift

oven/bun:1.3-alpine resolves to a newer patch (e.g. 1.3.14) than the version
used locally (1.3.5). Bun's lockfile text format can shift between patches,
making --frozen fail with "lockfile had changes" even though dependencies are
identical. Dropping --frozen keeps the lockfile for resolution without
enforcing a byte-for-byte match on re-serialisation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ockfile errors

Bun 1.3.14 enforces lockfile-is-frozen semantics even without --frozen when
it detects a bun.lock whose serialised format differs from the running version.
Dropping bun.lock from the COPY step lets the container generate a fresh
lockfile from package.json, eliminating the cross-version format conflict
entirely. Production deps are still fully resolved; only ephemeral container
reproducibility at the sub-patch level is relaxed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… mismatch

api/bun.lock is the root workspace lockfile (workspace root "ons"), but the
previous Docker build used context ./api which only provided api/package.json
(named "ons-api"). Bun detects the name mismatch and tries to regenerate the
lockfile, triggering frozen-lockfile enforcement and failing the build.

Fix: build from repo root so the real workspace bun.lock is available.
Both api/ and frontend/ package.json stubs are copied so Bun's workspace
resolver sees all members. Packages are hoisted to /app/node_modules;
WORKDIR switches to /app/api for the runtime so module resolution climbs up
and finds them correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…1.3.14

The old api/bun.lock was a copy of the root workspace lockfile (workspace root
named "ons"), but the Docker build context is ./api where package.json names
the package "ons-api". Bun detects the name mismatch and auto-enables frozen
mode, failing the build even without --frozen.

Fix: regenerated api/bun.lock in isolation using the same oven/bun:1.3-alpine
image as CI (Bun 1.3.14). The new lockfile correctly records "ons-api" as the
workspace root and passes --frozen --production cleanly. Verified locally with
docker build before committing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… Bun 1.3.14

frontend/bun.lock recorded "matching-frontend" as the workspace root name
(pre-rename), but frontend/package.json is now named "ons-app". Same mismatch
as the api lockfile — Bun auto-enables frozen mode on name mismatch and fails.

Regenerated in isolation using oven/bun:1.3-alpine (Bun 1.3.14). Verified with
docker build -f frontend/Dockerfile ./frontend before committing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ci.yml:
- Add Test (API) job running the full 199-test suite
- Add Type-check (Frontend) job
- Add Build (Frontend) job via `tsc && vite build`
- Add Docker build (Frontend) job
- Docker jobs now gate on their respective fast checks (needs:)
- Separate GHA cache scopes per Docker job to avoid collision
- Root bun.lock used for all Bun install steps (workspace-aware)

security.yml:
- Pin trivy-action to 0.30.0 (was @master — unpinned)
- Add `actions: read` permission for CodeQL
- Remove redundant comments, tighten formatting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the single ci.yml with two focused workflow files, one per package.
Each file is self-contained with its own trigger, concurrency group, and
typecheck → test/build → docker pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…al false positives

- trivy-action: 0.30.0 (non-existent) → v0.36.0 (current latest)
- .gitguardian.yml: allowlist for 9 dev defaults and test fixtures that appear
  in old commits — MongoDB dev passwords, test JWT/AES/HMAC fixtures, and
  staging placeholder values. These were never real credentials.

Note: the GitGuardian GitHub App reads .gitguardian.yml for ggshield-based
scans. Incidents already recorded in the dashboard for old commits must also
be marked as "ignored" or "won't fix" there to fully clear the PR check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…re branches

Previously both push and pull_request fired on every branch, causing two
identical runs per commit when a PR was open. Now:
  push    → main only (direct merges / hotfixes)
  pull_request → all PRs (no branch filter needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@GravityDarkLab
GravityDarkLab marked this pull request as ready for review May 31, 2026 21:26
Copilot AI review requested due to automatic review settings May 31, 2026 21:26
@GravityDarkLab GravityDarkLab added documentation Improvements or additions to documentation enhancement New feature or request good first issue Good for newcomers labels May 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

GravityDarkLab and others added 8 commits May 31, 2026 23:37
- env.ts: validate EMBEDDING_PROVIDER as "openai"|"local" at startup;
  make EMBEDDING_BASE_URL/OPENAI_API_KEY conditionally required per
  provider instead of always required — fixes startup failure when using
  the OpenAI provider without EMBEDDING_BASE_URL set
- admin.seed.ts: exit(1) in production instead of warn-and-continue,
  consistent with applicants.seed.ts
- README: replace hardcoded dev credential examples with placeholders,
  fix test count 197 → 199, fix typo "MOngoDB"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Attack surface: a JWT in localStorage is readable by any JS on the page
(XSS). An HttpOnly cookie is invisible to JS — the browser attaches it
automatically and the token is never accessible at the application layer.

API changes:
- auth.middleware: extract token from cookie first, Authorization header
  as fallback (keeps API clients and tests working without changes)
- admin.controller: login sets HttpOnly cookie (Secure in prod, SameSite=Lax,
  path=/api/v1/admin, maxAge derived from JWT_EXPIRY); no token in response body
- admin.controller: add POST /logout (clears cookie) and GET /me (session probe)
- admin.routes: register /logout and /me

Frontend changes:
- client.ts: all requests use credentials:'include'; no localStorage helpers;
  adminLogin() returns void; adminLogout() and getMe() added
- AuthContext: async /me probe on mount determines initial auth state; login()
  takes no args; logout() calls adminLogout() before clearing state
- ProtectedRoute: renders null while /me probe is in flight (avoids flash redirect)
- Login: passes no token to login()

Tests:
- Login test: asserts Set-Cookie header + no body.token instead of body.token
- Auth middleware test: broadened error regex to match new "Unauthorized" message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Info: rename to "Ons — Matching Platform API", add auth model description
- Security schemes: add CookieAuth (apiKey in cookie, primary for browser);
  BearerAuth now described as fallback for API clients/tooling
- components/responses/Unauthorized: add shared reusable response ref
- POST /admin/login: describe Set-Cookie header in response, no token in body;
  add Swagger UI note about cookie visibility limitation
- POST /admin/logout: new endpoint (clears cookie)
- GET /admin/me: new endpoint (session probe, returns adminId + adminRole)
- All protected routes: security updated to [CookieAuth, BearerAuth]
- All 401 responses on protected routes: use shared Unauthorized $ref
- AdminLoginResponse: remove token field, add description note
- AdminMeResponse: new schema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents unexpected breakage if Bun 2.0 ships with breaking changes.
The 1.3 range still receives patch updates (e.g. 1.3.14) automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: getMe() used the generic request() helper which calls
window.location.replace('/admin/login') on any 401. On the login page
the user is not authenticated, so /me → 401 → redirect → mount → /me
→ 401 → redirect... causing an infinite loop that exhausted the rate
limiter (20 req/min) in seconds.

Three fixes:
- client.ts: getMe() uses raw fetch and returns null on 401 — a 401
  here means "not logged in", not "session expired", so no redirect
- AuthContext: handle nullable getMe() return (null → unauthenticated)
- admin.routes.ts: register /logout and /me before use("*") so they
  are not covered by the admin rate limiter; neither is a brute-force
  surface (cookie probe + one-shot clear)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getMe() was being called on every page load including public routes
(/, /apply, /success) because AuthProvider wrapped the entire app.

Moved AuthProvider inside the /admin/* route so the session probe
only fires when the user navigates to an admin page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… data endpoints

20 req/min on all admin routes caused 429s during normal browsing:
a single page load fires 3-4 requests, React StrictMode doubles them
in development, and navigating a few pages exhausted the limit instantly.

- adminLoginRateLimiter: 10 req/min — tight, targets brute-force on /login
- adminRateLimiter: 200 req/min — headroom for real usage including StrictMode
- /login now uses adminLoginRateLimiter explicitly instead of the wildcard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@GravityDarkLab
GravityDarkLab merged commit b036d94 into main Jun 1, 2026
12 checks passed
@GravityDarkLab
GravityDarkLab deleted the claude/suspicious-elion-ef091f branch June 1, 2026 08:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request good first issue Good for newcomers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants