Add KYC manager moderation API
Build and deploy Docker image / build (push) Successful in 2m36s

This commit is contained in:
Ruslan Bakiev
2026-06-06 15:43:38 +07:00
parent 8cd885ab3e
commit d51d04d104
4 changed files with 157 additions and 2 deletions
+40
View File
@@ -32,6 +32,24 @@ function scopesFromPayload(payload: JWTPayload): string[] {
return []; return [];
} }
function claimList(payload: JWTPayload, key: string): string[] {
const value = (payload as Record<string, unknown>)[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<AuthContext> { export async function publicContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req); const token = getBearerToken(req);
if (!token) return { scopes: [] }; if (!token) return { scopes: [] };
@@ -68,6 +86,28 @@ export async function userContext(req: FastifyRequest): Promise<AuthContext> {
return { userId: payload.sub, scopes }; return { userId: payload.sub, scopes };
} }
export async function managerContext(req: FastifyRequest): Promise<AuthContext> {
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<AuthContext> { export async function m2mContext(): Promise<AuthContext> {
return { scopes: [], isM2M: true }; return { scopes: [], isM2M: true };
} }
+10 -1
View File
@@ -5,8 +5,9 @@ import mercurius from "mercurius";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import { publicTypeDefs, publicResolvers } from "./schemas/public.js"; import { publicTypeDefs, publicResolvers } from "./schemas/public.js";
import { userTypeDefs, userResolvers } from "./schemas/user.js"; import { userTypeDefs, userResolvers } from "./schemas/user.js";
import { managerTypeDefs, managerResolvers } from "./schemas/manager.js";
import { m2mTypeDefs, m2mResolvers } from "./schemas/m2m.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 PORT = Number.parseInt(process.env.PORT || "8000", 10);
const SENTRY_DSN = process.env.SENTRY_DSN || ""; const SENTRY_DSN = process.env.SENTRY_DSN || "";
@@ -74,6 +75,13 @@ await registerGraphqlEndpoint(
userResolvers, userResolvers,
userContext, userContext,
); );
await registerGraphqlEndpoint(
app,
"/graphql/manager",
managerTypeDefs,
managerResolvers,
managerContext,
);
await registerGraphqlEndpoint( await registerGraphqlEndpoint(
app, app,
"/graphql/m2m", "/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(`KYC server ready on port ${PORT}`);
console.log(` /graphql/public - public (optional auth)`); console.log(` /graphql/public - public (optional auth)`);
console.log(` /graphql/user - id token auth`); console.log(` /graphql/user - id token auth`);
console.log(` /graphql/manager - manager auth`);
console.log(` /graphql/m2m - internal services (no auth)`); console.log(` /graphql/m2m - internal services (no auth)`);
+106
View File
@@ -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(),
},
}