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:28 +05:00
parent 133d7b434b
commit ce10b41915
6 changed files with 1290 additions and 1620 deletions

3
.gitmodules vendored Normal file
View File

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

1
graphql-contracts Submodule

Submodule graphql-contracts added at 60d70c305d

2662
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,19 +10,17 @@
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@apollo/server": "^4.11.3",
"@fastify/cors": "^11.2.0",
"@prisma/client": "^6.5.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"@sentry/node": "^9.5.0",
"fastify": "^5.8.5",
"graphql": "^16.10.0",
"graphql-tag": "^2.12.6",
"jose": "^6.0.11",
"tigerbeetle-node": "^0.16.43",
"@sentry/node": "^9.5.0"
"mercurius": "^16.9.0",
"tigerbeetle-node": "^0.16.43"
},
"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,86 +1,106 @@
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 LOGTO_BILLING_AUDIENCE = process.env.LOGTO_BILLING_AUDIENCE || 'https://billing.optovia.ru'
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 LOGTO_BILLING_AUDIENCE =
process.env.LOGTO_BILLING_AUDIENCE || "https://billing.optovia.ru";
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
teamUuid?: string
scopes: string[]
isM2M?: boolean
userId?: string;
teamUuid?: string;
scopes: string[];
isM2M?: boolean;
}
function getBearerToken(req: Request): string {
const auth = req.headers.authorization || ''
if (!auth.startsWith('Bearer ')) {
throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
function getBearerToken(req: FastifyRequest): string {
const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) {
throw new GraphQLError("Missing Bearer token", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const token = auth.slice(7)
if (!token || token === 'undefined') {
throw new GraphQLError('Empty Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
const token = auth.slice(7);
if (!token || token === "undefined") {
throw new GraphQLError("Empty Bearer token", {
extensions: { code: "UNAUTHENTICATED" },
});
}
return token
return token;
}
function scopesFromPayload(payload: JWTPayload): string[] {
const scope = payload.scope
if (!scope) return []
if (typeof scope === 'string') return scope.split(' ')
if (Array.isArray(scope)) return scope as string[]
return []
const scope = payload.scope;
if (!scope) return [];
if (typeof scope === "string") return scope.split(" ");
if (Array.isArray(scope)) return scope as string[];
return [];
}
export async function m2mContext(): Promise<AuthContext> {
return { scopes: [], isM2M: true }
return { scopes: [], isM2M: true };
}
export async function teamContext(req: Request): Promise<AuthContext> {
const token = getBearerToken(req)
export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req);
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 BillingSessionMe { me { id activeTeamId } }`,
}),
})
const body = await response.json() as { data?: { me?: { id?: string; activeTeamId?: string } } }
const me = body.data?.me
});
const body = (await response.json()) as {
data?: { me?: { id?: string; activeTeamId?: string } };
};
const me = body.data?.me;
if (!me?.id || !me.activeTeamId) {
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
throw new GraphQLError("Unauthorized", {
extensions: { code: "UNAUTHENTICATED" },
});
}
return { userId: me.id, teamUuid: me.activeTeamId, scopes: ['teams:member'] }
return {
userId: me.id,
teamUuid: me.activeTeamId,
scopes: ["teams:member"],
};
}
const { payload } = await jwtVerify(token, jwks, {
issuer: LOGTO_ISSUER,
audience: LOGTO_BILLING_AUDIENCE,
})
});
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined
const scopes = scopesFromPayload(payload)
const teamUuid = (payload as Record<string, unknown>).team_uuid as
| string
| undefined;
const scopes = scopesFromPayload(payload);
if (!teamUuid || !scopes.includes('teams:member')) {
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
if (!teamUuid || !scopes.includes("teams:member")) {
throw new GraphQLError("Unauthorized", {
extensions: { code: "UNAUTHENTICATED" },
});
}
return { userId: payload.sub, teamUuid, scopes }
return { userId: payload.sub, teamUuid, scopes };
}
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
const missing = required.filter(s => !ctx.scopes.includes(s))
const missing = required.filter((s) => !ctx.scopes.includes(s));
if (missing.length > 0) {
throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, {
extensions: { code: 'FORBIDDEN' },
})
throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, {
extensions: { code: "FORBIDDEN" },
});
}
}

View File

@@ -1,64 +1,82 @@
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 { teamTypeDefs, teamResolvers } from './schemas/team.js'
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
import { m2mContext, teamContext, 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 { teamTypeDefs, teamResolvers } from "./schemas/team.js";
import { m2mTypeDefs, m2mResolvers } from "./schemas/m2m.js";
import { m2mContext, teamContext } 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 teamServer = new ApolloServer<AuthContext>({
typeDefs: teamTypeDefs,
resolvers: teamResolvers,
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 m2mServer = new ApolloServer<AuthContext>({
typeDefs: m2mTypeDefs,
resolvers: m2mResolvers,
introspection: true,
})
await registerGraphqlEndpoint(
app,
"/graphql/team",
teamTypeDefs,
teamResolvers,
teamContext,
);
await registerGraphqlEndpoint(
app,
"/graphql/m2m",
m2mTypeDefs,
m2mResolvers,
async () => m2mContext(),
);
await Promise.all([teamServer.start(), m2mServer.start()])
app.get("/health", async () => ({ status: "ok" }));
app.use(
'/graphql/team',
express.json(),
expressMiddleware(teamServer, {
context: async ({ req }) => teamContext(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(`Billing server ready on port ${PORT}`)
console.log(` /graphql/team - team access token auth`)
console.log(` /graphql/m2m - internal services (no auth)`)
})
await app.listen({ port: PORT, host: "0.0.0.0" });
console.log(`Billing server ready on port ${PORT}`);
console.log(` /graphql/team - team access token auth`);
console.log(` /graphql/m2m - internal services (no auth)`);