From d51d04d1042cdf90646cf4b9fb8aae3416bdd477 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:43:38 +0700 Subject: [PATCH] Add KYC manager moderation API --- graphql-contracts | 2 +- src/auth.ts | 40 ++++++++++++++++ src/index.ts | 11 ++++- src/schemas/manager.ts | 106 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/schemas/manager.ts diff --git a/graphql-contracts b/graphql-contracts index 36474ab..95486d7 160000 --- a/graphql-contracts +++ b/graphql-contracts @@ -1 +1 @@ -Subproject commit 36474ab670033752379a49ddaeadadafe1ed7cf4 +Subproject commit 95486d75fcab8bec22c728a4d1f402855d552baa diff --git a/src/auth.ts b/src/auth.ts index 8ca62a1..a0d8eb8 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -32,6 +32,24 @@ function scopesFromPayload(payload: JWTPayload): string[] { return []; } +function claimList(payload: JWTPayload, key: string): string[] { + const value = (payload as Record)[key]; + if (typeof value === "string") return value.split(" "); + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string"); + } + return []; +} + +function hasManagerClaim(payload: JWTPayload): boolean { + const scopes = scopesFromPayload(payload); + return ( + scopes.includes("manager") || + claimList(payload, "roles").includes("manager") || + claimList(payload, "permissions").includes("manager") + ); +} + export async function publicContext(req: FastifyRequest): Promise { const token = getBearerToken(req); if (!token) return { scopes: [] }; @@ -68,6 +86,28 @@ export async function userContext(req: FastifyRequest): Promise { return { userId: payload.sub, scopes }; } +export async function managerContext(req: FastifyRequest): Promise { + const token = getBearerToken(req); + if (!token) { + throw new GraphQLError("Unauthorized", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + const { payload } = await jwtVerify(token, jwks, { + issuer: LOGTO_ISSUER, + audience: LOGTO_KYC_AUDIENCE, + }); + if (!payload.sub || !hasManagerClaim(payload)) { + throw new GraphQLError("Manager access required", { + extensions: { code: "FORBIDDEN" }, + }); + } + return { + userId: payload.sub, + scopes: [...new Set([...scopesFromPayload(payload), "manager"])], + }; +} + export async function m2mContext(): Promise { return { scopes: [], isM2M: true }; } diff --git a/src/index.ts b/src/index.ts index 0957973..4791350 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,8 +5,9 @@ import mercurius from "mercurius"; import * as Sentry from "@sentry/node"; import { publicTypeDefs, publicResolvers } from "./schemas/public.js"; import { userTypeDefs, userResolvers } from "./schemas/user.js"; +import { managerTypeDefs, managerResolvers } from "./schemas/manager.js"; import { m2mTypeDefs, m2mResolvers } from "./schemas/m2m.js"; -import { publicContext, userContext, m2mContext } from "./auth.js"; +import { publicContext, userContext, managerContext, m2mContext } from "./auth.js"; const PORT = Number.parseInt(process.env.PORT || "8000", 10); const SENTRY_DSN = process.env.SENTRY_DSN || ""; @@ -74,6 +75,13 @@ await registerGraphqlEndpoint( userResolvers, userContext, ); +await registerGraphqlEndpoint( + app, + "/graphql/manager", + managerTypeDefs, + managerResolvers, + managerContext, +); await registerGraphqlEndpoint( app, "/graphql/m2m", @@ -88,4 +96,5 @@ await app.listen({ port: PORT, host: "0.0.0.0" }); console.log(`KYC server ready on port ${PORT}`); console.log(` /graphql/public - public (optional auth)`); console.log(` /graphql/user - id token auth`); +console.log(` /graphql/manager - manager auth`); console.log(` /graphql/m2m - internal services (no auth)`); diff --git a/src/schemas/manager.ts b/src/schemas/manager.ts new file mode 100644 index 0000000..0ecd6d3 --- /dev/null +++ b/src/schemas/manager.ts @@ -0,0 +1,106 @@ +import { GraphQLError } from 'graphql' +import { prisma } from '../db.js' +import type { AuthContext } from '../auth.js' + +export const managerTypeDefs = `#graphql + type KYCApplication { + id: Int! + uuid: String! + userId: String! + teamName: String + countryCode: String + workflowStatus: String! + score: Int! + contactPerson: String + contactEmail: String + contactPhone: String + countryData: String + createdAt: String! + updatedAt: String! + } + + type UpdateKYCApplicationStatusResult { + kycApplication: KYCApplication + success: Boolean! + } + + type Query { + managerKycApplications: [KYCApplication!]! + managerKycRequests: [KYCApplication!]! + } + + type Mutation { + updateKycApplicationStatus(uuid: String!, status: String!): UpdateKYCApplicationStatusResult! + updateKycRequestStatus(uuid: String!, status: String!): UpdateKYCApplicationStatusResult! + } +` + +function assertManager(ctx: AuthContext) { + if (!ctx.userId || !ctx.scopes.includes('manager')) { + throw new GraphQLError('Manager access required', { + extensions: { code: 'FORBIDDEN' }, + }) + } +} + +async function getCountryData(app: { objectId: number | null }) { + if (!app.objectId) return null + const details = await prisma.kYCDetailsRussia.findUnique({ where: { id: app.objectId } }) + if (!details) return null + return JSON.stringify({ + company_name: details.companyName, + company_full_name: details.companyFullName, + inn: details.inn, + kpp: details.kpp, + ogrn: details.ogrn, + address: details.address, + bank_name: details.bankName, + bik: details.bik, + correspondent_account: details.correspondentAccount, + }) +} + +async function updateStatus( + args: { uuid: string; status: string }, + ctx: AuthContext, +) { + assertManager(ctx) + const status = args.status.trim() + if (status.length === 0) { + throw new GraphQLError('Status is required') + } + const approved = status === 'approved' + const kycApplication = await prisma.kYCApplication.update({ + where: { uuid: args.uuid }, + data: { + workflowStatus: status, + approvedBy: approved ? ctx.userId : null, + approvedAt: approved ? new Date() : null, + }, + }) + return { kycApplication, success: true } +} + +export const managerResolvers = { + Query: { + managerKycApplications: async (_: unknown, __: unknown, ctx: AuthContext) => { + assertManager(ctx) + return prisma.kYCApplication.findMany({ orderBy: { updatedAt: 'desc' } }) + }, + managerKycRequests: async (_: unknown, __: unknown, ctx: AuthContext) => { + assertManager(ctx) + return prisma.kYCApplication.findMany({ orderBy: { updatedAt: 'desc' } }) + }, + }, + Mutation: { + updateKycApplicationStatus: (_: unknown, args: { uuid: string; status: string }, ctx: AuthContext) => + updateStatus(args, ctx), + updateKycRequestStatus: (_: unknown, args: { uuid: string; status: string }, ctx: AuthContext) => + updateStatus(args, ctx), + }, + KYCApplication: { + countryData: async (parent: { objectId: number | null }) => getCountryData(parent), + createdAt: (parent: { createdAt: Date }) => parent.createdAt.toISOString(), + updatedAt: (parent: { updatedAt: Date }) => parent.updatedAt.toISOString(), + }, +}