Skip to content

Commit f8a7f93

Browse files
idoshamunclaude
andcommitted
refactor(public-api): replace HMAC token injection with direct GraphQL execution
Eliminates the security concern of using short-lived HMAC tokens with fastify.inject() to bridge REST endpoints to GraphQL. Instead, the public API now executes GraphQL queries directly using the already authenticated FastifyRequest object. Changes: - Add executeGraphql utility that creates Context from the real request - Export DataSource via fastify.con decoration for route handlers - Update feed.ts and posts.ts routes to use direct execution - Add con property to FastifyInstance type declaration Benefits: - No HMAC tokens needed for internal communication - No HTTP request simulation via fastify.inject() - Uses the actual authenticated request object (userId, isPlus already set) - Cleaner trust boundary - PAT validates at entry, real request carries identity - Query parsing is cached for performance Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent be37293 commit f8a7f93

File tree

5 files changed

+106
-8
lines changed

5 files changed

+106
-8
lines changed

src/routes/public/feed.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FastifyInstance } from 'fastify';
2-
import { injectGraphql } from '../../compatibility/utils';
2+
import { executeGraphql } from './graphqlExecutor';
33

44
interface FeedQuery {
55
limit?: string;
@@ -130,8 +130,8 @@ export default async function (fastify: FastifyInstance): Promise<void> {
130130
const limit = Math.min(Math.max(1, parsedLimit), MAX_LIMIT);
131131
const { cursor } = request.query;
132132

133-
return injectGraphql(
134-
fastify,
133+
return executeGraphql(
134+
fastify.con!,
135135
{
136136
query: FEED_QUERY,
137137
variables: {
@@ -140,7 +140,7 @@ export default async function (fastify: FastifyInstance): Promise<void> {
140140
},
141141
},
142142
(json) => {
143-
const feed = (json as unknown as FeedResponse).data.feed;
143+
const feed = (json as FeedResponse['data']).feed;
144144
return {
145145
data: feed.edges.map(({ node }) => node),
146146
pagination: {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { execute, parse, DocumentNode, GraphQLError } from 'graphql';
2+
import type { FastifyRequest, FastifyReply } from 'fastify';
3+
import type { DataSource } from 'typeorm';
4+
import { Context } from '../../Context';
5+
import { schema } from '../../graphql';
6+
7+
export interface GraphqlPayload {
8+
query: string;
9+
operationName?: string;
10+
variables?: Record<string, unknown>;
11+
}
12+
13+
// Cache for parsed queries to avoid re-parsing
14+
const queryCache = new Map<string, DocumentNode>();
15+
16+
const parseQuery = (query: string): DocumentNode => {
17+
const cached = queryCache.get(query);
18+
if (cached) {
19+
return cached;
20+
}
21+
const document = parse(query);
22+
queryCache.set(query, document);
23+
return document;
24+
};
25+
26+
/**
27+
* Execute GraphQL directly without HTTP injection.
28+
* Uses the real FastifyRequest (already authenticated by PAT hook) to create the Context.
29+
* This eliminates the need for HMAC tokens and fastify.inject().
30+
*/
31+
export const executeGraphql = async <T>(
32+
con: DataSource,
33+
payload: GraphqlPayload,
34+
extractResponse: (obj: Record<string, unknown>) => T,
35+
req: FastifyRequest,
36+
res: FastifyReply,
37+
): Promise<FastifyReply> => {
38+
// Create context from the REAL request (already has userId, isPlus from PAT auth hook)
39+
const context = new Context(req, con);
40+
41+
// Parse the query (with caching for performance)
42+
const document = parseQuery(payload.query);
43+
44+
// Execute GraphQL directly - no HTTP injection
45+
const result = await execute({
46+
schema,
47+
document,
48+
contextValue: context,
49+
variableValues: payload.variables,
50+
operationName: payload.operationName,
51+
});
52+
53+
// Map GraphQL errors to HTTP status codes (same logic as injectGraphql)
54+
if (result.errors?.length) {
55+
const errors = result.errors as GraphQLError[];
56+
const code = errors[0]?.extensions?.code;
57+
58+
if (code === 'UNAUTHENTICATED') {
59+
return res.status(401).send({
60+
error: 'unauthorized',
61+
message: 'Authentication required',
62+
});
63+
}
64+
if (code === 'FORBIDDEN') {
65+
return res.status(403).send({
66+
error: 'forbidden',
67+
message: 'Access denied',
68+
});
69+
}
70+
if (code === 'VALIDATION_ERROR' || code === 'GRAPHQL_VALIDATION_FAILED') {
71+
return res.status(400).send({
72+
error: 'validation_error',
73+
message: errors[0]?.message || 'Invalid request',
74+
});
75+
}
76+
if (code === 'NOT_FOUND') {
77+
return res.status(404).send({
78+
error: 'not_found',
79+
message: errors[0]?.message || 'Resource not found',
80+
});
81+
}
82+
83+
// Unexpected errors
84+
req.log.warn(
85+
{ graphqlResponse: result },
86+
'unexpected graphql error when executing graphql request',
87+
);
88+
return res.status(500).send();
89+
}
90+
91+
const resBody = extractResponse(result.data as Record<string, unknown>);
92+
return res.status(resBody ? 200 : 204).send(resBody);
93+
};

src/routes/public/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ export default async function (
5353
fastify: FastifyInstance,
5454
con: DataSource,
5555
): Promise<void> {
56+
// Decorate fastify with the database connection for route handlers
57+
fastify.decorate('con', con);
58+
5659
// Register Swagger for OpenAPI documentation
5760
await fastify.register(fastifySwagger, {
5861
openapi: {

src/routes/public/posts.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FastifyInstance } from 'fastify';
2-
import { injectGraphql } from '../../compatibility/utils';
2+
import { executeGraphql } from './graphqlExecutor';
33

44
interface PostParams {
55
id: string;
@@ -108,14 +108,14 @@ export default async function (fastify: FastifyInstance): Promise<void> {
108108
// Auth middleware already validates the user, apiUserId is guaranteed
109109
const { id } = request.params;
110110

111-
return injectGraphql(
112-
fastify,
111+
return executeGraphql(
112+
fastify.con!,
113113
{
114114
query: POST_QUERY,
115115
variables: { id },
116116
},
117117
(json) => {
118-
const { post } = (json as unknown as PostQueryResponse).data;
118+
const { post } = json as PostQueryResponse['data'];
119119
return { data: post };
120120
},
121121
request,

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ declare module 'fastify' {
114114
interface FastifyInstance {
115115
// Used for tracing
116116
tracer?: opentelemetry.Tracer;
117+
// Used for public API routes to access database
118+
con?: import('typeorm').DataSource;
117119
}
118120
}
119121

0 commit comments

Comments
 (0)