Use Fastify Mercurius and GraphQL contracts
Some checks are pending
Build Docker Image / build (push) Waiting to run

This commit is contained in:
Ruslan Bakiev
2026-06-01 00:25:27 +05:00
parent 147202022a
commit 62beff96a1
6 changed files with 1264 additions and 1619 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "graphql-contracts"]
path = graphql-contracts
url = git@gitea.dsrptlab.com:optovia/kyc-graphql-contracts.git

1
graphql-contracts Submodule

Submodule graphql-contracts added at 36474ab670

2644
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,19 +9,17 @@
"start": "prisma migrate deploy && node dist/index.js"
},
"dependencies": {
"@apollo/server": "^4.11.3",
"@fastify/cors": "^11.2.0",
"@prisma/client": "^6.5.0",
"@sentry/node": "^9.5.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"fastify": "^5.8.5",
"graphql": "^16.10.0",
"graphql-tag": "^2.12.6",
"jose": "^6.0.11",
"mercurius": "^16.9.0",
"mongodb": "^6.13.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.0",
"prisma": "^6.5.0",
"tsx": "^4.19.0",

View File

@@ -1,65 +1,67 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
import { GraphQLError } from 'graphql'
import type { Request } from 'express'
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { GraphQLError } from "graphql";
import type { FastifyRequest } from "fastify";
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
const TEAMS_USER_GRAPHQL_URL = process.env.TEAMS_USER_GRAPHQL_URL || 'https://teams.optovia.ru/graphql/user/'
const SESSION_TOKEN_PREFIX = 'optovia-session:'
const LOGTO_JWKS_URL =
process.env.LOGTO_JWKS_URL || "https://auth.optovia.ru/oidc/jwks";
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || "https://auth.optovia.ru/oidc";
const TEAMS_USER_GRAPHQL_URL =
process.env.TEAMS_USER_GRAPHQL_URL ||
"https://teams.optovia.ru/graphql/user/";
const SESSION_TOKEN_PREFIX = "optovia-session:";
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL));
export interface AuthContext {
userId?: string
scopes: string[]
isM2M?: boolean
userId?: string;
scopes: string[];
isM2M?: boolean;
}
function getBearerToken(req: Request): string | null {
const auth = req.headers.authorization || ''
if (!auth.startsWith('Bearer ')) return null
const token = auth.slice(7)
if (!token || token === 'undefined') return null
return token
function getBearerToken(req: FastifyRequest): string | null {
const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) return null;
const token = auth.slice(7);
if (!token || token === "undefined") return null;
return token;
}
export async function publicContext(req: Request): Promise<AuthContext> {
// Optional auth - try to extract userId if token present
const token = getBearerToken(req)
if (!token) return { scopes: [] }
try {
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
return { userId: payload.sub, scopes: [] }
} catch {
return { scopes: [] }
}
export async function publicContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req);
if (!token) return { scopes: [] };
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER });
return { userId: payload.sub, scopes: [] };
}
export async function userContext(req: Request): Promise<AuthContext> {
const token = getBearerToken(req)
export async function userContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req);
if (!token) {
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GraphQLError("Unauthorized", {
extensions: { code: "UNAUTHENTICATED" },
});
}
if (token.startsWith(SESSION_TOKEN_PREFIX)) {
const response = await fetch(TEAMS_USER_GRAPHQL_URL, {
method: 'POST',
method: "POST",
headers: {
'content-type': 'application/json',
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query: `query KycSessionMe { me { id } }` }),
})
const body = await response.json() as { data?: { me?: { id?: string } } }
const userId = body.data?.me?.id
});
const body = (await response.json()) as { data?: { me?: { id?: string } } };
const userId = body.data?.me?.id;
if (!userId) {
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GraphQLError("Unauthorized", {
extensions: { code: "UNAUTHENTICATED" },
});
}
return { userId, scopes: [] }
return { userId, scopes: [] };
}
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
return { userId: payload.sub, scopes: [] }
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER });
return { userId: payload.sub, scopes: [] };
}
export async function m2mContext(): Promise<AuthContext> {
return { scopes: [], isM2M: true }
return { scopes: [], isM2M: true };
}

View File

@@ -1,80 +1,91 @@
import express from 'express'
import cors from 'cors'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import * as Sentry from '@sentry/node'
import { publicTypeDefs, publicResolvers } from './schemas/public.js'
import { userTypeDefs, userResolvers } from './schemas/user.js'
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
import { publicContext, userContext, m2mContext, type AuthContext } from './auth.js'
import Fastify from "fastify";
import type { FastifyInstance, FastifyRequest } from "fastify";
import cors from "@fastify/cors";
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 { m2mTypeDefs, m2mResolvers } from "./schemas/m2m.js";
import { publicContext, userContext, m2mContext } from "./auth.js";
const PORT = parseInt(process.env.PORT || '8000', 10)
const SENTRY_DSN = process.env.SENTRY_DSN || ''
const PORT = Number.parseInt(process.env.PORT || "8000", 10);
const SENTRY_DSN = process.env.SENTRY_DSN || "";
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 0.01,
release: process.env.RELEASE_VERSION || '1.0.0',
environment: process.env.ENVIRONMENT || 'production',
})
release: process.env.RELEASE_VERSION || "1.0.0",
environment: process.env.ENVIRONMENT || "production",
});
}
const app = express()
const app = Fastify();
await app.register(cors, { origin: ["https://optovia.ru"], credentials: true });
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
type GraphqlBody = {
query?: string;
variables?: Record<string, unknown>;
operationName?: string;
};
const publicServer = new ApolloServer<AuthContext>({
typeDefs: publicTypeDefs,
resolvers: publicResolvers,
introspection: true,
})
async function registerGraphqlEndpoint(
server: FastifyInstance,
path: string,
schema: string,
resolvers: unknown,
context: (request: FastifyRequest) => Promise<unknown> | unknown,
) {
await server.register(
async (route) => {
await route.register(mercurius, {
schema,
resolvers: resolvers as never,
routes: false,
});
route.post("/", async (request, reply) => {
const body = request.body as GraphqlBody;
if (typeof body.query !== "string") {
throw new Error("GraphQL query is required");
}
return reply.graphql(
body.query,
(await context(request)) as Record<string, unknown>,
body.variables,
body.operationName,
);
});
},
{ prefix: path },
);
}
const userServer = new ApolloServer<AuthContext>({
typeDefs: userTypeDefs,
resolvers: userResolvers,
introspection: true,
})
await registerGraphqlEndpoint(
app,
"/graphql/public",
publicTypeDefs,
publicResolvers,
publicContext,
);
await registerGraphqlEndpoint(
app,
"/graphql/user",
userTypeDefs,
userResolvers,
userContext,
);
await registerGraphqlEndpoint(
app,
"/graphql/m2m",
m2mTypeDefs,
m2mResolvers,
async () => m2mContext(),
);
const m2mServer = new ApolloServer<AuthContext>({
typeDefs: m2mTypeDefs,
resolvers: m2mResolvers,
introspection: true,
})
app.get("/health", async () => ({ status: "ok" }));
await Promise.all([publicServer.start(), userServer.start(), m2mServer.start()])
app.use(
'/graphql/public',
express.json(),
expressMiddleware(publicServer, {
context: async ({ req }) => publicContext(req as unknown as import('express').Request),
}) as unknown as express.RequestHandler,
)
app.use(
'/graphql/user',
express.json(),
expressMiddleware(userServer, {
context: async ({ req }) => userContext(req as unknown as import('express').Request),
}) as unknown as express.RequestHandler,
)
app.use(
'/graphql/m2m',
express.json(),
expressMiddleware(m2mServer, {
context: async () => m2mContext(),
}) as unknown as express.RequestHandler,
)
app.get('/health', (_, res) => {
res.json({ status: 'ok' })
})
app.listen(PORT, '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/m2m - internal services (no auth)`)
})
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/m2m - internal services (no auth)`);