Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0303ed8
feat: add static api key middleware for dev stats
ulemons Mar 19, 2026
3d6896b
fix: lint
ulemons Mar 19, 2026
0dfcefb
fix: remove local secret
ulemons Mar 19, 2026
6ac6f60
fix: use db oriented api keys
ulemons Mar 19, 2026
0206c9a
fix: remove useless env var
ulemons Mar 19, 2026
748ab15
fix: lint
ulemons Mar 19, 2026
5da1213
fix: review
ulemons Mar 20, 2026
33608e3
feat: add query layer
ulemons Mar 19, 2026
7d330df
feat: add filtering on query layer
ulemons Mar 19, 2026
a1d3713
feat: refactor in dal
ulemons Mar 19, 2026
18f9328
feat: add affiliations
ulemons Mar 20, 2026
68efd6d
feat: adding logs
ulemons Mar 20, 2026
0d03b2c
fix: lint
ulemons Mar 20, 2026
6f70eae
fix: lint
ulemons Mar 20, 2026
162abfb
fix: created at error
ulemons Mar 20, 2026
5e7a873
fix: createdAt as date
ulemons Mar 20, 2026
9bd11ca
feat: adding logs
ulemons Mar 23, 2026
2a933f8
refactor: simplify buildTimeline
ulemons Mar 23, 2026
ed4dc32
fix: lint
ulemons Mar 23, 2026
ef036f9
fix: remove logs
ulemons Mar 23, 2026
c9988c0
fix: remove comments
ulemons Mar 23, 2026
a6120bd
fix: change logging
ulemons Mar 23, 2026
45b30e3
refactor: create dal for affiliations
ulemons Mar 23, 2026
da9e76c
fix: lint
ulemons Mar 23, 2026
3274cdd
refactor: export affiliation on dal
ulemons Mar 23, 2026
e6f6aba
refactor: simplify longestDateRange
ulemons Mar 23, 2026
3ac015d
fix: filter first relevant orgs to avoid timeouts
ulemons Mar 24, 2026
6b1e34c
fix: align url
ulemons Mar 24, 2026
4662768
fix: test not joining for member count
ulemons Mar 24, 2026
e5684b7
fix: add safe wrap
ulemons Mar 24, 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 backend/src/api/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { devStatsRouter } from './v1/dev-stats'
export function publicRouter(): Router {
const router = Router()

router.use('/v1/dev-stats', staticApiKeyMiddleware(), devStatsRouter())
router.use('/v1', staticApiKeyMiddleware(), devStatsRouter())
Copy link

Choose a reason for hiding this comment

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

Route mount path breaks all existing OAuth2 endpoints

High Severity

Changing the mount path from '/v1/dev-stats' to '/v1' causes staticApiKeyMiddleware to intercept ALL /v1/* requests, not just dev-stats ones. When an OAuth2-authenticated request arrives (e.g., POST /v1/members), the middleware tries to validate the OAuth2 Bearer token as a static API key, fails, and calls next(new UnauthorizedError(...)). Express then skips the subsequent oauth2Middleware layer entirely and routes the error straight to errorHandler, returning 401 for all existing OAuth2-protected endpoints. Additionally, the endpoint path becomes /v1/affiliations instead of the documented /v1/dev-stats/affiliations.

Fix in Cursor Fix in Web

router.use('/v1', oauth2Middleware(AUTH0_CONFIG), v1Router())
router.use(errorHandler)

Expand Down
64 changes: 64 additions & 0 deletions backend/src/api/public/v1/dev-stats/getAffiliations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import {
findMembersByGithubHandles,
findVerifiedEmailsByMemberIds,
optionsQx,
resolveAffiliationsByMemberIds,
} from '@crowd/data-access-layer'

import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

const MAX_HANDLES = 1000

const bodySchema = z.object({
githubHandles: z
.array(z.string().min(1))
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

bodySchema accepts raw strings; unlike other public endpoints (e.g. lfids: z.string().trim()), this allows handles with leading/trailing whitespace that will never match lower(mi.value). Consider adding .trim() (and possibly .toLowerCase()/normalization) at validation time so lookup behavior is predictable.

Suggested change
.array(z.string().min(1))
.array(z.string().trim().min(1))

Copilot uses AI. Check for mistakes.
.min(1)
.max(MAX_HANDLES, `Maximum ${MAX_HANDLES} handles per request`),
})

export async function getAffiliations(req: Request, res: Response): Promise<void> {
const { githubHandles } = validateOrThrow(bodySchema, req.body)
const qx = optionsQx(req)

const lowercasedHandles = githubHandles.map((h) => h.toLowerCase())

// Step 1: find verified members by github handles
const memberRows = await findMembersByGithubHandles(qx, lowercasedHandles)

const foundHandles = new Set(memberRows.map((r) => r.githubHandle.toLowerCase()))
const notFound = githubHandles.filter((h) => !foundHandles.has(h.toLowerCase()))

if (memberRows.length === 0) {
ok(res, { total_found: 0, contributors: [], notFound })
return
Comment on lines +35 to +37
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Response uses total_found (snake_case) while other public v1 endpoints consistently use camelCase response keys (e.g. projectAffiliations, memberId, workExperiences). Consider renaming this to totalFound (and keeping response casing consistent across this API surface).

Copilot uses AI. Check for mistakes.
}

const memberIds = memberRows.map((r) => r.memberId)

// Step 2: fetch verified emails
const emailRows = await findVerifiedEmailsByMemberIds(qx, memberIds)

const emailsByMember = new Map<string, string[]>()
for (const row of emailRows) {
const list = emailsByMember.get(row.memberId) ?? []
list.push(row.email)
emailsByMember.set(row.memberId, list)
}

// Step 3: resolve affiliations (conflict resolution, gap-filling, selection priority)
const affiliationsByMember = await resolveAffiliationsByMemberIds(qx, memberIds)

// Step 4: build response
const contributors = memberRows.map((member) => ({
githubHandle: member.githubHandle,
name: member.displayName,
emails: emailsByMember.get(member.memberId) ?? [],
affiliations: affiliationsByMember.get(member.memberId) ?? [],
}))

ok(res, { total_found: contributors.length, contributors, notFound })
}
7 changes: 4 additions & 3 deletions backend/src/api/public/v1/dev-stats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ import { Router } from 'express'

import { createRateLimiter } from '@/api/apiRateLimiter'
import { requireScopes } from '@/api/public/middlewares/requireScopes'
import { safeWrap } from '@/middlewares/errorMiddleware'
import { SCOPES } from '@/security/scopes'

import { getAffiliations } from './getAffiliations'

const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })

export function devStatsRouter(): Router {
const router = Router()

router.use(rateLimiter)

router.post('/affiliations', requireScopes([SCOPES.READ_AFFILIATIONS]), (_req, res) => {
res.json({ status: 'ok' })
})
router.post('/affiliations', requireScopes([SCOPES.READ_AFFILIATIONS]), safeWrap(getAffiliations))

return router
}
Loading
Loading