Skip to content

Commit ab8fa2a

Browse files
authored
Merge pull request #187 from deploystackio/feat/imp1
Feat/imp1
2 parents afd9131 + 0c9b640 commit ab8fa2a

File tree

10 files changed

+1874
-1553
lines changed

10 files changed

+1874
-1553
lines changed

docs/development/backend/api-pagination.mdx

Lines changed: 269 additions & 125 deletions
Large diffs are not rendered by default.

docs/development/backend/api-security.mdx

Lines changed: 220 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -58,41 +58,123 @@ Understanding Fastify's hook execution order is essential for proper security im
5858
```typescript
5959
import { requireGlobalAdmin } from '../../../middleware/roleMiddleware';
6060

61-
export default async function secureRoute(fastify: FastifyInstance) {
62-
fastify.post<{ Body: RequestInput }>('/protected-endpoint', {
61+
// Reusable Schema Constants
62+
const REQUEST_SCHEMA = {
63+
type: 'object',
64+
properties: {
65+
name: { type: 'string', minLength: 1, description: 'Name is required' },
66+
value: { type: 'string', description: 'Value field' }
67+
},
68+
required: ['name', 'value'],
69+
additionalProperties: false
70+
} as const;
71+
72+
const SUCCESS_RESPONSE_SCHEMA = {
73+
type: 'object',
74+
properties: {
75+
success: { type: 'boolean' },
76+
message: { type: 'string' }
77+
},
78+
required: ['success', 'message']
79+
} as const;
80+
81+
const ERROR_RESPONSE_SCHEMA = {
82+
type: 'object',
83+
properties: {
84+
success: { type: 'boolean', default: false },
85+
error: { type: 'string' }
86+
},
87+
required: ['success', 'error']
88+
} as const;
89+
90+
// TypeScript interfaces
91+
interface RequestBody {
92+
name: string;
93+
value: string;
94+
}
95+
96+
interface SuccessResponse {
97+
success: boolean;
98+
message: string;
99+
}
100+
101+
interface ErrorResponse {
102+
success: boolean;
103+
error: string;
104+
}
105+
106+
export default async function secureRoute(server: FastifyInstance) {
107+
server.post('/protected-endpoint', {
108+
preValidation: requireGlobalAdmin(), // ✅ CORRECT: Runs before validation
63109
schema: {
64110
tags: ['Protected'],
65111
summary: 'Protected endpoint',
66112
description: 'Requires admin permissions',
67113
security: [{ cookieAuth: [] }],
68-
body: createSchema(RequestSchema),
114+
115+
// Fastify validation schema
116+
body: REQUEST_SCHEMA,
117+
118+
// OpenAPI documentation (same schema, reused)
119+
requestBody: {
120+
required: true,
121+
content: {
122+
'application/json': {
123+
schema: REQUEST_SCHEMA
124+
}
125+
}
126+
},
127+
69128
response: {
70-
200: createSchema(SuccessResponseSchema.describe('Success')),
71-
401: createSchema(ErrorResponseSchema.describe('Unauthorized')),
72-
403: createSchema(ErrorResponseSchema.describe('Forbidden')),
73-
400: createSchema(ErrorResponseSchema.describe('Bad Request'))
129+
200: {
130+
...SUCCESS_RESPONSE_SCHEMA,
131+
description: 'Success'
132+
},
133+
401: {
134+
...ERROR_RESPONSE_SCHEMA,
135+
description: 'Unauthorized'
136+
},
137+
403: {
138+
...ERROR_RESPONSE_SCHEMA,
139+
description: 'Forbidden'
140+
},
141+
400: {
142+
...ERROR_RESPONSE_SCHEMA,
143+
description: 'Bad Request'
144+
}
74145
}
75-
},
76-
preValidation: requireGlobalAdmin(), // ✅ CORRECT: Runs before validation,
146+
}
77147
}, async (request, reply) => {
78148
// If we reach here, user is authorized AND input is validated
79-
const validatedData = request.body;
149+
const validatedData = request.body as RequestBody;
150+
80151
// Your business logic here
152+
const successResponse: SuccessResponse = {
153+
success: true,
154+
message: 'Operation completed successfully'
155+
};
156+
const jsonString = JSON.stringify(successResponse);
157+
return reply.status(200).type('application/json').send(jsonString);
81158
});
82159
}
83160
```
84161

85162
### ❌ Insecure Pattern: preHandler for Authorization
86163

87164
```typescript
88-
export default async function insecureRoute(fastify: FastifyInstance) {
89-
fastify.post<{ Body: RequestInput }>('/protected-endpoint', {
165+
export default async function insecureRoute(server: FastifyInstance) {
166+
server.post('/protected-endpoint', {
90167
schema: {
91168
// Schema definition...
92-
body: zodToJsonSchema(RequestSchema, {
93-
$refStrategy: 'none',
94-
target: 'openApi3'
95-
})
169+
body: {
170+
type: 'object',
171+
properties: {
172+
name: { type: 'string', minLength: 1 },
173+
value: { type: 'string' }
174+
},
175+
required: ['name', 'value'],
176+
additionalProperties: false
177+
}
96178
},
97179
preHandler: requireGlobalAdmin(), // ❌ WRONG: Runs after validation
98180
}, async (request, reply) => {
@@ -161,22 +243,24 @@ For endpoints that support both web users (cookies) and CLI users (OAuth2 Bearer
161243
```typescript
162244
import { requireAuthenticationAny, requireOAuthScope } from '../../middleware/oauthMiddleware';
163245

164-
fastify.get('/dual-auth-endpoint', {
165-
schema: {
166-
security: [
167-
{ cookieAuth: [] }, // Cookie authentication
168-
{ bearerAuth: [] } // OAuth2 Bearer token
169-
]
170-
},
171-
preValidation: [
172-
requireAuthenticationAny(), // Accept either auth method
173-
requireOAuthScope('your:scope') // Enforce OAuth2 scope
174-
]
175-
}, async (request, reply) => {
176-
// Endpoint accessible via both authentication methods
177-
const authType = request.tokenPayload ? 'oauth2' : 'cookie';
178-
const userId = request.user!.id;
179-
});
246+
export default async function dualAuthRoute(server: FastifyInstance) {
247+
server.get('/dual-auth-endpoint', {
248+
preValidation: [
249+
requireAuthenticationAny(), // Accept either auth method
250+
requireOAuthScope('your:scope') // Enforce OAuth2 scope
251+
],
252+
schema: {
253+
security: [
254+
{ cookieAuth: [] }, // Cookie authentication
255+
{ bearerAuth: [] } // OAuth2 Bearer token
256+
]
257+
}
258+
}, async (request, reply) => {
259+
// Endpoint accessible via both authentication methods
260+
const authType = request.tokenPayload ? 'oauth2' : 'cookie';
261+
const userId = request.user!.id;
262+
});
263+
}
180264
```
181265

182266
For detailed OAuth2 implementation, see the [Backend OAuth Implementation Guide](/development/backend/oauth-providers) and [Backend Security Policy](/development/backend/security#oauth2-server-security).
@@ -188,31 +272,102 @@ For endpoints that operate within team contexts (e.g., `/teams/:teamId/resource`
188272
```typescript
189273
import { requireTeamPermission } from '../../../middleware/roleMiddleware';
190274

191-
export default async function teamResourceRoute(fastify: FastifyInstance) {
192-
fastify.post<{
193-
Params: { teamId: string };
194-
Body: CreateResourceRequest;
195-
}>('/teams/:teamId/resources', {
275+
// Reusable Schema Constants
276+
const CREATE_RESOURCE_SCHEMA = {
277+
type: 'object',
278+
properties: {
279+
name: { type: 'string', minLength: 1, description: 'Name is required' },
280+
description: { type: 'string', description: 'Optional description' }
281+
},
282+
required: ['name'],
283+
additionalProperties: false
284+
} as const;
285+
286+
const SUCCESS_RESPONSE_SCHEMA = {
287+
type: 'object',
288+
properties: {
289+
success: { type: 'boolean' },
290+
message: { type: 'string' }
291+
},
292+
required: ['success', 'message']
293+
} as const;
294+
295+
const ERROR_RESPONSE_SCHEMA = {
296+
type: 'object',
297+
properties: {
298+
success: { type: 'boolean', default: false },
299+
error: { type: 'string' }
300+
},
301+
required: ['success', 'error']
302+
} as const;
303+
304+
// TypeScript interfaces
305+
interface CreateResourceRequest {
306+
name: string;
307+
description?: string;
308+
}
309+
310+
interface SuccessResponse {
311+
success: boolean;
312+
message: string;
313+
}
314+
315+
interface ErrorResponse {
316+
success: boolean;
317+
error: string;
318+
}
319+
320+
export default async function teamResourceRoute(server: FastifyInstance) {
321+
server.post('/teams/:teamId/resources', {
322+
preValidation: requireTeamPermission('resources.create'), // ✅ Team-aware authorization
196323
schema: {
197324
tags: ['Team Resources'],
198325
summary: 'Create team resource',
199326
description: 'Creates a resource within the specified team context',
200327
security: [{ cookieAuth: [] }],
201-
params: zodToJsonSchema(z.object({
202-
teamId: z.string().min(1, 'Team ID is required')
203-
})),
204-
body: zodToJsonSchema(CreateResourceSchema),
328+
329+
params: {
330+
type: 'object',
331+
properties: {
332+
teamId: { type: 'string', minLength: 1 }
333+
},
334+
required: ['teamId'],
335+
additionalProperties: false
336+
},
337+
338+
body: CREATE_RESOURCE_SCHEMA,
339+
340+
requestBody: {
341+
required: true,
342+
content: {
343+
'application/json': {
344+
schema: CREATE_RESOURCE_SCHEMA
345+
}
346+
}
347+
},
348+
205349
response: {
206-
201: zodToJsonSchema(SuccessResponseSchema),
207-
401: zodToJsonSchema(ErrorResponseSchema.describe('Unauthorized')),
208-
403: zodToJsonSchema(ErrorResponseSchema.describe('Forbidden - Not team member or insufficient permissions')),
209-
400: zodToJsonSchema(ErrorResponseSchema.describe('Bad Request'))
350+
201: {
351+
...SUCCESS_RESPONSE_SCHEMA,
352+
description: 'Resource created successfully'
353+
},
354+
401: {
355+
...ERROR_RESPONSE_SCHEMA,
356+
description: 'Unauthorized'
357+
},
358+
403: {
359+
...ERROR_RESPONSE_SCHEMA,
360+
description: 'Forbidden - Not team member or insufficient permissions'
361+
},
362+
400: {
363+
...ERROR_RESPONSE_SCHEMA,
364+
description: 'Bad Request'
365+
}
210366
}
211-
},
212-
preValidation: requireTeamPermission('resources.create'), // ✅ Team-aware authorization
367+
}
213368
}, async (request, reply) => {
214-
const { teamId } = request.params;
215-
const resourceData = request.body;
369+
const { teamId } = request.params as { teamId: string };
370+
const resourceData = request.body as CreateResourceRequest;
216371

217372
// User is guaranteed to be:
218373
// 1. Authenticated
@@ -221,6 +376,12 @@ export default async function teamResourceRoute(fastify: FastifyInstance) {
221376
// 4. Input is validated
222377

223378
// Your business logic here
379+
const successResponse: SuccessResponse = {
380+
success: true,
381+
message: `Resource "${resourceData.name}" created successfully`
382+
};
383+
const jsonString = JSON.stringify(successResponse);
384+
return reply.status(201).type('application/json').send(jsonString);
224385
});
225386
}
226387
```
@@ -358,21 +519,21 @@ team_user: [
358519

359520
```typescript
360521
// Global admin only
361-
fastify.delete('/admin/users/:id', {
362-
schema: { /* ... */ },
522+
server.delete('/admin/users/:id', {
363523
preValidation: requireGlobalAdmin(),
524+
schema: { /* ... */ }
364525
}, handler);
365526

366527
// Specific permission required
367-
fastify.post('/settings/bulk', {
368-
schema: { /* ... */ },
528+
server.post('/settings/bulk', {
369529
preValidation: requirePermission('settings.edit'),
530+
schema: { /* ... */ }
370531
}, handler);
371532

372533
// User can access own data OR admin can access any
373-
fastify.get('/users/:id/profile', {
374-
schema: { /* ... */ },
534+
server.get('/users/:id/profile', {
375535
preValidation: requireOwnershipOrAdmin(getUserIdFromParams),
536+
schema: { /* ... */ }
376537
}, handler);
377538
```
378539

@@ -441,13 +602,13 @@ For complex authorization requirements:
441602

442603
```typescript
443604
// Multiple checks in sequence
444-
fastify.post('/complex-endpoint', {
445-
schema: { /* ... */ },
605+
server.post('/complex-endpoint', {
446606
preValidation: [
447607
requireAuthentication(), // Must be logged in
448608
requireRole('team_member'), // Must have team role
449609
requirePermission('data.write') // Must have write permission
450610
],
611+
schema: { /* ... */ }
451612
}, handler);
452613
```
453614

@@ -465,9 +626,9 @@ async function conditionalAuth(request: FastifyRequest, reply: FastifyReply) {
465626
}
466627
}
467628

468-
fastify.post('/conditional-endpoint', {
469-
schema: { /* ... */ },
629+
server.post('/conditional-endpoint', {
470630
preValidation: conditionalAuth,
631+
schema: { /* ... */ }
471632
}, handler);
472633
```
473634

0 commit comments

Comments
 (0)