Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@
"uuid": "^9.0.0",
"validator": "^13.7.0",
"verify-github-webhook": "^1.0.1",
"zlib-sync": "^0.1.8"
"zlib-sync": "^0.1.8",
"zod": "^4.3.6"
},
"private": true,
"devDependencies": {
Expand All @@ -150,6 +151,7 @@
"@types/bunyan-format": "^0.2.5",
"@types/config": "^3.3.0",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.17",
"@types/html-to-text": "^8.1.1",
"@types/node": "~18.0.4",
"@types/sanitize-html": "^2.6.2",
Expand Down
6 changes: 2 additions & 4 deletions backend/src/api/public/middlewares/oauth2Middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { auth } from 'express-oauth2-jwt-bearer'
import { UnauthorizedError } from '@crowd/common'

import type { Auth0Configuration } from '@/conf/configTypes'
import type { ApiRequest, Auth0TokenPayload } from '@/types/api'
import type { Auth0TokenPayload } from '@/types/api'

function resolveActor(req: Request, _res: Response, next: NextFunction): void {
const payload = (req.auth?.payload ?? {}) as Auth0TokenPayload
Expand All @@ -18,11 +18,9 @@ function resolveActor(req: Request, _res: Response, next: NextFunction): void {

const id = rawId.replace(/@clients$/, '')

const authReq = req as ApiRequest

const scopes = typeof payload.scope === 'string' ? payload.scope.split(' ').filter(Boolean) : []

authReq.actor = { id, type: 'service', scopes }
req.actor = { id, type: 'service', scopes }

next()
}
Expand Down
5 changes: 2 additions & 3 deletions backend/src/api/public/middlewares/requireScopes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import type { NextFunction, Response } from 'express'
import type { NextFunction, Request, Response } from 'express'

import { InsufficientScopeError, UnauthorizedError } from '@crowd/common'

import { Scope } from '@/security/scopes'
import type { ApiRequest } from '@/types/api'

export const requireScopes =
(required: Scope[], mode: 'all' | 'any' = 'all') =>
(req: ApiRequest, _res: Response, next: NextFunction) => {
(req: Request, _res: Response, next: NextFunction) => {
if (!req.actor) {
next(new UnauthorizedError())
return
Expand Down
4 changes: 2 additions & 2 deletions backend/src/api/public/v1/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Router } from 'express'

import { identitiesRouter } from './identities'
import { membersRouter } from './members'

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

router.use('/identities', identitiesRouter())
router.use('/members', membersRouter())

return router
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { NotFoundError } from '@crowd/common'
import { fetchMemberIdentities, findMemberById, optionsQx } from '@crowd/data-access-layer'
import { MemberField } from '@crowd/data-access-layer/src/members/base'

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

const paramsSchema = z.object({
memberId: z.uuid(),
})

export async function getMemberIdentities(req: Request, res: Response): Promise<void> {
const { memberId } = validateOrThrow(paramsSchema, req.params)
const qx = optionsQx(req)

const member = await findMemberById(qx, memberId, [MemberField.ID])

if (!member) {
throw new NotFoundError('Member profile not found')
}

const identities = await fetchMemberIdentities(qx, memberId)

ok(res, { identities })
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { NotFoundError } from '@crowd/common'
import { findMemberById, optionsQx } from '@crowd/data-access-layer'
import { MemberField } from '@crowd/data-access-layer/src/members/base'

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

const paramsSchema = z.object({
memberId: z.uuid(),
})

// const bodySchema = z.object({
// verified: z.boolean(),
// verifiedBy: z.string().optional(),
// })

export async function updateMemberIdentity(req: Request, res: Response): Promise<void> {
const qx = optionsQx(req)

const { memberId } = validateOrThrow(paramsSchema, req.params)

const member = await findMemberById(qx, memberId, [MemberField.ID])

if (!member) {
throw new NotFoundError('Member profile not found')
}

// const { verified, verifiedBy } = validateOrThrow(bodySchema, req.body)

// if verified is true, update the verified flag for the identity + verifiedBy source
// if verified is false, then chcek if the identity has any activity, if it doesn't then soft delete
// if it has activity, then unmerge the identity from the member

ok(res, { success: true })
}
43 changes: 43 additions & 0 deletions backend/src/api/public/v1/members/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Router } from 'express'

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

import { getMemberIdentities } from './identities/getMemberIdentities'
import { updateMemberIdentity } from './identities/updateMemberIdentity'
import { getMemberMaintainerRoles } from './maintainer-roles/getMemberMaintainerRoles'
import { resolveMemberByIdentities } from './resolveMember'
import { getMemberWorkExperiences } from './work-experiences/getMemberWorkExperiences'

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

router.post('/resolve', requireScopes([SCOPES.READ_MEMBERS]), safeWrap(resolveMemberByIdentities))

router.get(
'/:memberId/identities',
requireScopes([SCOPES.READ_MEMBER_IDENTITIES]),
safeWrap(getMemberIdentities),
)

router.patch(
'/:memberId/identities/:identityId',
requireScopes([SCOPES.WRITE_MEMBER_IDENTITIES]),
safeWrap(updateMemberIdentity),
)

router.get(
'/:memberId/maintainer-roles',
requireScopes([SCOPES.READ_MAINTAINER_ROLES]),
safeWrap(getMemberMaintainerRoles),
)

router.get(
'/:memberId/work-experiences',
requireScopes([SCOPES.READ_WORK_EXPERIENCES]),
safeWrap(getMemberWorkExperiences),
)

return router
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { NotFoundError } from '@crowd/common'
import { findMaintainerRoles, findMemberById, optionsQx } from '@crowd/data-access-layer'
import { MemberField } from '@crowd/data-access-layer/src/members/base'

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

const paramsSchema = z.object({
memberId: z.uuid(),
})

export async function getMemberMaintainerRoles(req: Request, res: Response): Promise<void> {
const { memberId } = validateOrThrow(paramsSchema, req.params)
const qx = optionsQx(req)

const member = await findMemberById(qx, memberId, [MemberField.ID])

if (!member) {
throw new NotFoundError('Member profile not found')
}

const maintainerRoles = await findMaintainerRoles(qx, [memberId])

ok(res, { maintainerRoles })
}
41 changes: 41 additions & 0 deletions backend/src/api/public/v1/members/resolveMember.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { ConflictError, NotFoundError } from '@crowd/common'
import { findMemberIdsByIdentities, optionsQx } from '@crowd/data-access-layer'
import { IMemberIdentity, MemberIdentityType, PlatformType } from '@crowd/types'

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

const bodySchema = z.object({
lfids: z.array(z.string().trim().min(1)),
emails: z.array(z.email()).optional(),
})

export async function resolveMemberByIdentities(req: Request, res: Response): Promise<void> {
const { lfids, emails } = validateOrThrow(bodySchema, req.body)

const qx = optionsQx(req)

const identities: Partial<IMemberIdentity>[] = [
...lfids.map((lfid) => ({
platform: PlatformType.LFID,
type: MemberIdentityType.USERNAME,
value: lfid,
})),
...(emails?.map((email) => ({ type: MemberIdentityType.EMAIL, value: email })) ?? []),
]

const memberIds = await findMemberIdsByIdentities(qx, identities)

if (memberIds.length === 0) {
throw new NotFoundError('Member profile not found!')
} else if (memberIds.length > 1) {
throw new ConflictError('Conflicting identities!')
}

const memberId = memberIds[0]

ok(res, { memberId })
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { NotFoundError } from '@crowd/common'
import { fetchMemberOrganizations, findMemberById, optionsQx } from '@crowd/data-access-layer'
import { MemberField } from '@crowd/data-access-layer/src/members/base'

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

const paramsSchema = z.object({
memberId: z.uuid(),
})

export async function getMemberWorkExperiences(req: Request, res: Response): Promise<void> {
const { memberId } = validateOrThrow(paramsSchema, req.params)
const qx = optionsQx(req)

const member = await findMemberById(qx, memberId, [MemberField.ID])

if (!member) {
throw new NotFoundError('Member profile not found')
}

const workExperiences = await fetchMemberOrganizations(qx, memberId)

ok(res, { workExperiences })
}
10 changes: 5 additions & 5 deletions backend/src/security/scopes.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
export const SCOPES = {
READ_MEMBERS: 'read:members',
READ_IDENTITIES: 'read:identities',
WRITE_IDENTITIES: 'write:identities',
READ_ROLES: 'read:roles',
READ_MEMBER_IDENTITIES: 'read:member-identities',
WRITE_MEMBER_IDENTITIES: 'write:member-identities',
READ_MAINTAINER_ROLES: 'read:maintainer-roles',
READ_WORK_EXPERIENCES: 'read:work-experiences',
WRITE_WORK_EXPERIENCES: 'write:work-experiences',
READ_PROJECTS_AFFILIATIONS: 'read:projects-affiliations',
WRITE_PROJECTS_AFFILIATIONS: 'write:projects-affiliations',
READ_PROJECT_AFFILIATIONS: 'read:project-affiliations',
WRITE_PROJECT_AFFILIATIONS: 'write:project-affiliations',
} as const

export type Scope = (typeof SCOPES)[keyof typeof SCOPES]
9 changes: 0 additions & 9 deletions backend/src/types/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Request } from 'express'
import type { JWTPayload } from 'express-oauth2-jwt-bearer'

/**
Expand All @@ -19,11 +18,3 @@ export interface Actor {
id: string
scopes: string[]
}

/**
* Express request with authenticated actor
* Use req.actor to check identity and permissions
*/
export interface ApiRequest extends Request {
actor: Actor
}
9 changes: 9 additions & 0 deletions backend/src/types/express.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Actor } from '@/types/api'

declare global {
namespace Express {
interface Request {
actor: Actor
}
}
}
17 changes: 17 additions & 0 deletions backend/src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z } from 'zod'

import { BadRequestError } from '@crowd/common'

export function validateOrThrow<T extends z.Schema>(schema: T, data: unknown): z.infer<T> {
const result = schema.safeParse(data)

if (!result.success) {
const messages = result.error.issues.map((issue) => {
const path = issue.path.length ? `${issue.path.join('.')}: ` : ''
return `${path}${issue.message}`
})
throw new BadRequestError(messages.join('; '))
}

return result.data
}
2 changes: 1 addition & 1 deletion backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"noUnusedParameters": false,
"sourceMap": true,
"target": "es2018",
"types": ["node"],
"types": ["node", "express"],
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
Expand Down
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions services/libs/data-access-layer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './security_insights'
export * from './systemSettings'
export * from './integrations'
export * from './auditLogs'
export * from './maintainers'
Loading
Loading