diff --git a/api/src/app.ts b/api/src/app.ts index 635534da867..92693fbb095 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -30,9 +30,11 @@ import csrf from './plugins/csrf.js'; import notFound from './plugins/not-found.js'; import shadowCapture from './plugins/shadow-capture.js'; import growthBook from './plugins/growth-book.js'; +import serviceBearerAuth from './plugins/service-bearer-auth.js'; import * as publicRoutes from './routes/public/index.js'; import * as protectedRoutes from './routes/protected/index.js'; +import { classroomRoutes } from './routes/apps/classroom.js'; import { API_LOCATION, @@ -171,6 +173,7 @@ export const build = async ( void fastify.register(notFound); void fastify.register(prismaPlugin); void fastify.register(bouncer); + await fastify.register(serviceBearerAuth); // Routes requiring authentication: void fastify.register(async function (fastify, _opts) { @@ -196,7 +199,6 @@ export const build = async ( fastify.addHook('onRequest', fastify.send401IfNoUser); await fastify.register(protectedRoutes.userGetRoutes); - await fastify.register(protectedRoutes.classroomRoutes); }); // Routes that redirect if access is denied: @@ -232,6 +234,12 @@ export const build = async ( }); void fastify.register(examEnvironmentOpenRoutes); + // Service-to-service app routes (API key auth): + void fastify.register(async function (fastify) { + fastify.addHook('onRequest', fastify.validateBearerToken); + await fastify.register(classroomRoutes, { prefix: '/apps/classroom' }); + }); + if (FCC_ENABLE_SENTRY_ROUTES ?? fastify.gb.isOn('sentry-routes')) { void fastify.register(publicRoutes.sentryRoutes); } diff --git a/api/src/plugins/service-bearer-auth.test.ts b/api/src/plugins/service-bearer-auth.test.ts new file mode 100644 index 00000000000..500fbd1d5c6 --- /dev/null +++ b/api/src/plugins/service-bearer-auth.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; + +vi.mock('../utils/env', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + TPA_API_BEARER_TOKEN: 'test-api-secret-key' + }; +}); + +import serviceBearerAuth from './service-bearer-auth.js'; + +describe('service-bearer-auth plugin', () => { + let fastify: FastifyInstance; + + beforeEach(async () => { + fastify = Fastify(); + await fastify.register(serviceBearerAuth); + fastify.addHook('onRequest', fastify.validateBearerToken); + fastify.get('/test', (_req, reply) => { + void reply.send({ ok: true }); + }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + test('should allow request with valid bearer token', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'Bearer test-api-secret-key' + } + }); + + expect(res.statusCode).toEqual(200); + expect(res.json()).toEqual({ ok: true }); + }); + + test('should return 401 when authorization header is missing', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test' + }); + + expect(res.statusCode).toEqual(401); + expect(res.json()).toEqual({ error: 'Bearer token is required' }); + }); + + test('should return 401 when authorization header has no Bearer prefix', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'test-api-secret-key' + } + }); + + expect(res.statusCode).toEqual(401); + expect(res.json()).toEqual({ error: 'Bearer token is required' }); + }); + + test('should return 401 when bearer token is empty', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'Bearer ' + } + }); + + expect(res.statusCode).toEqual(401); + expect(res.json()).toEqual({ error: 'Invalid bearer token' }); + }); + + test('should return 401 when bearer token is wrong', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'Bearer wrong-key' + } + }); + + expect(res.statusCode).toEqual(401); + expect(res.json()).toEqual({ error: 'Invalid bearer token' }); + }); +}); diff --git a/api/src/plugins/service-bearer-auth.ts b/api/src/plugins/service-bearer-auth.ts new file mode 100644 index 00000000000..8398ee7d33f --- /dev/null +++ b/api/src/plugins/service-bearer-auth.ts @@ -0,0 +1,54 @@ +import crypto from 'node:crypto'; +import type { + FastifyPluginCallback, + FastifyRequest, + FastifyReply +} from 'fastify'; +import fp from 'fastify-plugin'; + +import { TPA_API_BEARER_TOKEN } from '../utils/env.js'; + +declare module 'fastify' { + interface FastifyInstance { + validateBearerToken: ( + req: FastifyRequest, + reply: FastifyReply + ) => Promise; + } +} + +const plugin: FastifyPluginCallback = (fastify, _options, done) => { + fastify.decorate( + 'validateBearerToken', + async function (req: FastifyRequest, reply: FastifyReply) { + const secret = TPA_API_BEARER_TOKEN ?? ''; + if (secret.length === 0) { + fastify.log.error('TPA_API_BEARER_TOKEN is not configured'); + await reply + .status(500) + .send({ error: 'Service authentication not configured' }); + return; + } + + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + await reply.status(401).send({ error: 'Bearer token is required' }); + return; + } + + const token = authHeader.slice(7); + if ( + token.length !== secret.length || + !crypto.timingSafeEqual(Buffer.from(token), Buffer.from(secret)) + ) { + await reply.status(401).send({ error: 'Invalid bearer token' }); + return; + } + } + ); + + done(); +}; + +export default fp(plugin, { name: 'service-bearer-auth' }); diff --git a/api/src/routes/apps/classroom.test.ts b/api/src/routes/apps/classroom.test.ts new file mode 100644 index 00000000000..d1b4cb0d1d6 --- /dev/null +++ b/api/src/routes/apps/classroom.test.ts @@ -0,0 +1,293 @@ +import { describe, test, expect, afterEach, vi } from 'vitest'; + +vi.mock('../../utils/env', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + TPA_API_BEARER_TOKEN: 'test-classroom-api-secret' + }; +}); + +import request from 'supertest'; + +import { createUserInput } from '../../utils/create-user.js'; +import { + defaultUserEmail, + defaultUserId, + resetDefaultUser, + setupServer +} from '../../../vitest.utils.js'; + +const BEARER_TOKEN = 'test-classroom-api-secret'; + +const classroomUserEmail = 'student1@example.com'; +const nonClassroomUserEmail = 'student2@example.com'; +const classroomUserId = '000000000000000000000001'; +const nonClassroomUserId = '000000000000000000000002'; + +function post(url: string) { + return request(fastifyTestInstance.server) + .post(url) + .set('authorization', `Bearer ${BEARER_TOKEN}`); +} + +describe('classroom routes', () => { + setupServer(); + + afterEach(async () => { + vi.restoreAllMocks(); + + await fastifyTestInstance.prisma.user.deleteMany({ + where: { email: { in: [classroomUserEmail, nonClassroomUserEmail] } } + }); + + await resetDefaultUser(); + }); + + describe('Without bearer token', () => { + test('POST get-user-id returns 401', async () => { + const res = await request(fastifyTestInstance.server) + .post('/apps/classroom/get-user-id') + .send({ email: 'someone@example.com' }); + + expect(res.status).toBe(401); + expect(res.body).toStrictEqual({ error: 'Bearer token is required' }); + }); + + test('POST get-user-data returns 401', async () => { + const res = await request(fastifyTestInstance.server) + .post('/apps/classroom/get-user-data') + .send({ userIds: [defaultUserId] }); + + expect(res.status).toBe(401); + expect(res.body).toStrictEqual({ error: 'Bearer token is required' }); + }); + }); + + describe('With wrong bearer token', () => { + test('POST get-user-id returns 401', async () => { + const res = await request(fastifyTestInstance.server) + .post('/apps/classroom/get-user-id') + .set('authorization', 'Bearer wrong-key') + .send({ email: 'someone@example.com' }); + + expect(res.status).toBe(401); + expect(res.body).toStrictEqual({ error: 'Invalid bearer token' }); + }); + + test('POST get-user-data returns 401', async () => { + const res = await request(fastifyTestInstance.server) + .post('/apps/classroom/get-user-data') + .set('authorization', 'Bearer wrong-key') + .send({ userIds: [defaultUserId] }); + + expect(res.status).toBe(401); + expect(res.body).toStrictEqual({ error: 'Invalid bearer token' }); + }); + }); + + describe('Authenticated with API key', () => { + describe('POST /apps/classroom/get-user-id', () => { + test('returns 400 for missing email', async () => { + const res = await post('/apps/classroom/get-user-id').send({}); + + expect(res.status).toBe(400); + }); + + test('returns 400 for invalid email format', async () => { + const res = await post('/apps/classroom/get-user-id').send({ + email: 'not-an-email' + }); + + expect(res.status).toBe(400); + }); + + test('returns 200 with empty userId when no classroom account matches email', async () => { + const res = await post('/apps/classroom/get-user-id').send({ + email: defaultUserEmail + }); + + expect(res.status).toBe(200); + expect(res.body).toStrictEqual({ userId: '' }); + }); + + test('returns 200 with userId for a classroom account', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { isClassroomAccount: true } + }); + + const res = await post('/apps/classroom/get-user-id').send({ + email: defaultUserEmail + }); + + expect(res.status).toBe(200); + expect(res.body).toStrictEqual({ userId: defaultUserId }); + }); + + test('returns 500 when the database query fails', async () => { + const original = fastifyTestInstance.prisma.user.findFirst; + fastifyTestInstance.prisma.user.findFirst = vi + .fn() + .mockRejectedValue(new Error('test')) as typeof original; + + const res = await post('/apps/classroom/get-user-id').send({ + email: defaultUserEmail + }); + + fastifyTestInstance.prisma.user.findFirst = original; + + expect(res.status).toBe(500); + expect(res.body).toStrictEqual({ + error: 'Failed to retrieve user id' + }); + }); + }); + + describe('POST /apps/classroom/get-user-data', () => { + test('returns 400 when more than 50 userIds are provided', async () => { + const tooMany = Array.from( + { length: 51 }, + (_, i) => `${String(i).padStart(24, '0')}` + ); + + const res = await post('/apps/classroom/get-user-data').send({ + userIds: tooMany + }); + + expect(res.status).toBe(400); + }); + + test('returns 200 with empty data for empty userIds array', async () => { + const res = await post('/apps/classroom/get-user-data').send({ + userIds: [] + }); + + expect(res.status).toBe(200); + expect(res.body).toStrictEqual({ data: {} }); + }); + + test('returns data only for classroom accounts', async () => { + const now = Date.now(); + + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { + isClassroomAccount: true, + completedChallenges: [ + { + id: 'challenge-default', + completedDate: now, + files: [] + } + ] + } + }); + + await fastifyTestInstance.prisma.user.create({ + data: { + ...createUserInput(classroomUserEmail), + id: classroomUserId, + isClassroomAccount: true, + completedChallenges: [ + { + id: 'challenge-student', + completedDate: now + 1, + files: [] + } + ] + } + }); + + await fastifyTestInstance.prisma.user.create({ + data: { + ...createUserInput(nonClassroomUserEmail), + id: nonClassroomUserId, + isClassroomAccount: false, + completedChallenges: [] + } + }); + + const res = await post('/apps/classroom/get-user-data').send({ + userIds: [defaultUserId, classroomUserId, nonClassroomUserId] + }); + + expect(res.status).toBe(200); + const responseBody = res.body as { + data: Record< + string, + Array<{ id: string; completedDate: number }> | undefined + >; + }; + expect(Object.keys(responseBody.data)).toEqual( + expect.arrayContaining([defaultUserId, classroomUserId]) + ); + expect(responseBody.data).not.toHaveProperty(nonClassroomUserId); + + expect(responseBody.data[defaultUserId]?.[0]).toStrictEqual({ + id: 'challenge-default', + completedDate: now + }); + expect(responseBody.data[classroomUserId]?.[0]).toStrictEqual({ + id: 'challenge-student', + completedDate: now + 1 + }); + }); + + test('response contains only id and completedDate', async () => { + const now = Date.now(); + + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { + isClassroomAccount: true, + completedChallenges: [ + { + id: 'challenge-shape-test', + completedDate: now, + solution: 'http://example.com/solution', + files: [ + { + contents: 'some code', + ext: 'js', + key: 'indexjs', + name: 'index' + } + ] + } + ] + } + }); + + const res = await post('/apps/classroom/get-user-data').send({ + userIds: [defaultUserId] + }); + + expect(res.status).toBe(200); + const responseBody = res.body as { + data: Record>>; + }; + const challenge = responseBody.data[defaultUserId]![0]!; + expect(Object.keys(challenge)).toStrictEqual(['id', 'completedDate']); + }); + + test('returns 500 when the database query fails', async () => { + const original = fastifyTestInstance.prisma.user.findMany; + fastifyTestInstance.prisma.user.findMany = vi + .fn() + .mockRejectedValue(new Error('test')) as typeof original; + + const res = await post('/apps/classroom/get-user-data').send({ + userIds: [defaultUserId] + }); + + fastifyTestInstance.prisma.user.findMany = original; + + expect(res.status).toBe(500); + expect(res.body).toStrictEqual({ + error: 'Failed to retrieve user data' + }); + }); + }); + }); +}); diff --git a/api/src/routes/apps/classroom.ts b/api/src/routes/apps/classroom.ts new file mode 100644 index 00000000000..186b0bb60d9 --- /dev/null +++ b/api/src/routes/apps/classroom.ts @@ -0,0 +1,88 @@ +import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; +import { normalizeDate } from '../../utils/normalize.js'; +import * as schemas from '../../schemas.js'; + +/** + * Routes for the classroom app integration. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done The callback to signal that the plugin is ready. + */ +export const classroomRoutes: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + fastify.post( + '/get-user-id', + { + schema: schemas.classroomGetUserIdSchema + }, + async (request, reply) => { + const { email } = request.body; + + try { + const user = await fastify.prisma.user.findFirst({ + where: { email, isClassroomAccount: true }, + select: { id: true } + }); + + if (!user) { + return reply.send({ userId: '' }); + } + + return reply.send({ + userId: user.id + }); + } catch (error) { + fastify.log.error(error); + return reply.code(500).send({ error: 'Failed to retrieve user id' }); + } + } + ); + + fastify.post( + '/get-user-data', + { + schema: schemas.classroomGetUserDataSchema + }, + async (request, reply) => { + const { userIds } = request.body; + + try { + const users = await fastify.prisma.user.findMany({ + where: { + id: { in: userIds }, + isClassroomAccount: true + }, + select: { + id: true, + completedChallenges: true + } + }); + + const userData: Record< + string, + { id: string; completedDate: number }[] + > = {}; + + users.forEach(user => { + userData[user.id] = user.completedChallenges.map(challenge => ({ + id: challenge.id, + completedDate: normalizeDate(challenge.completedDate) + })); + }); + + return reply.send({ + data: userData + }); + } catch (error) { + fastify.log.error(error); + return reply.code(500).send({ error: 'Failed to retrieve user data' }); + } + } + ); + + done(); +}; diff --git a/api/src/routes/protected/classroom.test.ts b/api/src/routes/protected/classroom.test.ts deleted file mode 100644 index f21614c4e16..00000000000 --- a/api/src/routes/protected/classroom.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, test, expect, beforeAll, afterEach, vi } from 'vitest'; - -import { createUserInput } from '../../utils/create-user.js'; -import { - createSuperRequest, - defaultUserEmail, - defaultUserId, - devLogin, - resetDefaultUser, - setupServer, - superRequest -} from '../../../vitest.utils.js'; - -describe('classroom routes', () => { - setupServer(); - - describe('Authenticated user', () => { - let setCookies: string[]; - let superPost: ReturnType; - - const classroomUserEmail = 'student1@example.com'; - const nonClassroomUserEmail = 'student2@example.com'; - const classroomUserId = '000000000000000000000001'; - const nonClassroomUserId = '000000000000000000000002'; - - beforeAll(async () => { - setCookies = await devLogin(); - superPost = createSuperRequest({ method: 'POST', setCookies }); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - - // Cleanup users created by these tests - await fastifyTestInstance.prisma.user.deleteMany({ - where: { email: { in: [classroomUserEmail, nonClassroomUserEmail] } } - }); - - // Reset default user to a clean state - await resetDefaultUser(); - }); - - describe('POST /api/protected/classroom/get-user-id', () => { - test('returns 400 for missing email', async () => { - const missingRes = await superPost( - '/api/protected/classroom/get-user-id' - ).send({}); - - expect(missingRes.status).toBe(400); - }); - - test('returns 200 with empty userId for invalid email format', async () => { - const invalidRes = await superPost( - '/api/protected/classroom/get-user-id' - ).send({ email: 'not-an-email' }); - - expect(invalidRes.status).toBe(200); - expect(invalidRes.body).toStrictEqual({ userId: '' }); - }); - - test('returns 200 with empty userId when no classroom account matches email', async () => { - // Default user is not a classroom account by default - const res = await superPost( - '/api/protected/classroom/get-user-id' - ).send({ email: defaultUserEmail }); - - expect(res.status).toBe(200); - expect(res.body).toStrictEqual({ userId: '' }); - }); - - test('returns 200 with userId for a classroom account', async () => { - // Make the default user a classroom account - await fastifyTestInstance.prisma.user.update({ - where: { id: defaultUserId }, - data: { isClassroomAccount: true } - }); - - const res = await superPost( - '/api/protected/classroom/get-user-id' - ).send({ email: defaultUserEmail }); - - expect(res.status).toBe(200); - expect(res.body).toStrictEqual({ userId: defaultUserId }); - }); - - test('returns 500 when the database query fails', async () => { - vi.spyOn( - fastifyTestInstance.prisma.user, - 'findFirst' - ).mockRejectedValue(new Error('test')); - - const res = await superPost( - '/api/protected/classroom/get-user-id' - ).send({ email: defaultUserEmail }); - - expect(res.status).toBe(500); - expect(res.body).toStrictEqual({ error: 'Failed to retrieve user id' }); - }); - }); - - describe('POST /api/protected/classroom/get-user-data', () => { - test('returns 400 when more than 50 userIds are provided', async () => { - const tooMany = Array.from({ length: 51 }, (_, i) => `id-${i}`); - - const res = await superPost( - '/api/protected/classroom/get-user-data' - ).send({ userIds: tooMany }); - - expect(res.status).toBe(400); - expect(res.body).toStrictEqual({ - error: 'Too many users requested. Maximum 50 allowed.' - }); - }); - - test('returns data only for classroom accounts', async () => { - const now = Date.now(); - - // Make default user a classroom account with one completed challenge - await fastifyTestInstance.prisma.user.update({ - where: { id: defaultUserId }, - data: { - isClassroomAccount: true, - completedChallenges: [ - { - id: 'challenge-default', - completedDate: now, - files: [] - } - ] - } - }); - - // Create an additional classroom user - await fastifyTestInstance.prisma.user.create({ - data: { - ...createUserInput(classroomUserEmail), - id: classroomUserId, - isClassroomAccount: true, - completedChallenges: [ - { - id: 'challenge-student', - completedDate: now + 1, - files: [] - } - ] - } - }); - - // Create a non-classroom user that should be filtered out - await fastifyTestInstance.prisma.user.create({ - data: { - ...createUserInput(nonClassroomUserEmail), - id: nonClassroomUserId, - isClassroomAccount: false, - completedChallenges: [] - } - }); - - const res = await superPost( - '/api/protected/classroom/get-user-data' - ).send({ - userIds: [defaultUserId, classroomUserId, nonClassroomUserId] - }); - - expect(res.status).toBe(200); - const responseBody = res.body as { - data: Record< - string, - Array<{ id: string; completedDate: number }> | undefined - >; - }; - expect(Object.keys(responseBody.data)).toEqual( - expect.arrayContaining([defaultUserId, classroomUserId]) - ); - expect(responseBody.data).not.toHaveProperty(nonClassroomUserId); - - expect(responseBody.data[defaultUserId]?.[0]).toMatchObject({ - id: 'challenge-default', - completedDate: now - }); - expect(responseBody.data[classroomUserId]?.[0]).toMatchObject({ - id: 'challenge-student', - completedDate: now + 1 - }); - }); - - test('returns 500 when the database query fails', async () => { - vi.spyOn(fastifyTestInstance.prisma.user, 'findMany').mockRejectedValue( - new Error('test') - ); - - const res = await superPost( - '/api/protected/classroom/get-user-data' - ).send({ userIds: [defaultUserId] }); - - expect(res.status).toBe(500); - expect(res.body).toStrictEqual({ - error: 'Failed to retrieve user data' - }); - }); - }); - }); - - describe('Unauthenticated user', () => { - test('POST requests are rejected with 401', async () => { - const res = await superRequest( - '/api/protected/classroom/get-user-id', - { - method: 'POST' - }, - { sendCSRFToken: false } - ).send({ email: 'someone@example.com' }); - - expect(res.status).toBe(401); - }); - }); -}); diff --git a/api/src/routes/protected/classroom.ts b/api/src/routes/protected/classroom.ts deleted file mode 100644 index 921933c7bf0..00000000000 --- a/api/src/routes/protected/classroom.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; -import { - normalizeChallenges, - NormalizedChallenge -} from '../../utils/normalize.js'; -import * as schemas from '../../schemas/classroom/classroom.js'; - -/** - * Fastify plugin for classroom-related protected routes. - * Provides endpoint for retrieving user data for classrooms. - * @param fastify - The Fastify instance. - * @param _options - Plugin options (unused). - * @param done - Callback to signal plugin registration is complete. - */ -export const classroomRoutes: FastifyPluginCallbackTypebox = ( - fastify, - _options, - done -) => { - // Endpoint to retrieve a user's ID from a user's email. - // If we send a 404 error here, it will stop the entire classroom process from working. - // Instead, we indicate that the user was not found through a null response and continue. - fastify.post( - '/api/protected/classroom/get-user-id', - { - schema: schemas.classroomGetUserIdSchema - }, - async (request, reply) => { - const { email } = request.body; - - // Basic email validation - return empty userId for invalid emails - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return reply.send({ userId: '' }); - } - - try { - // Find the user by email - const user = await fastify.prisma.user.findFirst({ - where: { email, isClassroomAccount: true }, - select: { id: true } - }); - - if (!user) { - return reply.send({ userId: '' }); - } - - return reply.send({ - userId: user.id - }); - } catch (error) { - fastify.log.error(error); - return reply.code(500).send({ error: 'Failed to retrieve user id' }); - } - } - ); - - // Endpoint to retrieve user(s) data from a list of user ids - fastify.post( - '/api/protected/classroom/get-user-data', - { - schema: schemas.classroomGetUserDataSchema - }, - async (request, reply) => { - const { userIds = [] } = request.body; - - // Limit number of users per request for performance - // Send custom error message if this is exceeded - if (userIds.length > 50) { - return reply.code(400).send({ - error: 'Too many users requested. Maximum 50 allowed.' - }); - } - - try { - // Find all the requested users by user id - const users = await fastify.prisma.user.findMany({ - where: { - id: { in: userIds }, - isClassroomAccount: true - }, - select: { - id: true, - completedChallenges: true - } - }); - - // Map to transform user data into the required format - const userData: Record = {}; - - users.forEach(user => { - // Normalize challenges - const normalizedChallenges = normalizeChallenges( - user.completedChallenges - ); - - userData[user.id] = normalizedChallenges; - }); - - return reply.send({ - data: userData - }); - } catch (error) { - fastify.log.error(error); - return reply.code(500).send({ error: 'Failed to retrieve user data' }); - } - } - ); - - done(); -}; diff --git a/api/src/routes/protected/index.ts b/api/src/routes/protected/index.ts index 87e738f8035..01c1fb9208e 100644 --- a/api/src/routes/protected/index.ts +++ b/api/src/routes/protected/index.ts @@ -1,6 +1,5 @@ export * from './certificate.js'; export * from './challenge.js'; -export * from './classroom.js'; export * from './donate.js'; export * from './settings.js'; export * from './user.js'; diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 88335b2a29e..d21a35c06d1 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -51,3 +51,7 @@ export { } from './schemas/user/exam-environment-token.js'; export { sentryPostEvent } from './schemas/sentry/event.js'; export { signout } from './schemas/signout/signout.js'; +export { + classroomGetUserIdSchema, + classroomGetUserDataSchema +} from './schemas/classroom/classroom.js'; diff --git a/api/src/schemas/classroom/classroom.ts b/api/src/schemas/classroom/classroom.ts index bd56308c45e..3484531f28c 100644 --- a/api/src/schemas/classroom/classroom.ts +++ b/api/src/schemas/classroom/classroom.ts @@ -1,30 +1,37 @@ import { Type } from '@fastify/type-provider-typebox'; export const classroomGetUserIdSchema = { body: Type.Object({ - email: Type.String({ maxLength: 1024 }) + email: Type.String({ format: 'email', maxLength: 1024 }) }), response: { 200: Type.Object({ userId: Type.String() }), + 400: Type.Object({ error: Type.String() }), + 401: Type.Object({ error: Type.String() }), 500: Type.Object({ error: Type.String() }) } }; export const classroomGetUserDataSchema = { body: Type.Object({ - userIds: Type.Array(Type.String()) + userIds: Type.Array( + Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }), + { maxItems: 50 } + ) }), response: { 200: Type.Object({ data: Type.Record( - Type.String(), + Type.String({ maxLength: 24 }), Type.Array( Type.Object({ id: Type.String(), completedDate: Type.Number() }) - ) + ), + { propertyNames: { maxLength: 24 } } ) }), 400: Type.Object({ error: Type.String() }), + 401: Type.Object({ error: Type.String() }), 500: Type.Object({ error: Type.String() }) } }; diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index f38d3e3389a..2a1dfd59a05 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -158,6 +158,15 @@ if (process.env.FREECODECAMP_NODE_ENV !== 'development') { 'fastify_api_sdk_client_key_from_growthbook_dashboard', 'The GROWTHBOOK_FASTIFY_CLIENT_KEY env should be changed from the default value.' ); + assert.ok( + process.env.TPA_API_BEARER_TOKEN, + 'TPA_API_BEARER_TOKEN should be set.' + ); + assert.notEqual( + process.env.TPA_API_BEARER_TOKEN, + 'tpa_api_bearer_token_from_dashboard', + 'The TPA_API_BEARER_TOKEN env should be changed from the default value.' + ); } export const HOME_LOCATION = process.env.HOME_LOCATION; @@ -217,6 +226,7 @@ export const GROWTHBOOK_FASTIFY_API_HOST = process.env.GROWTHBOOK_FASTIFY_API_HOST; export const GROWTHBOOK_FASTIFY_CLIENT_KEY = process.env.GROWTHBOOK_FASTIFY_CLIENT_KEY; +export const TPA_API_BEARER_TOKEN = process.env.TPA_API_BEARER_TOKEN; function undefinedOrBool(val: string | undefined): undefined | boolean { if (!val) { diff --git a/api/turbo.json b/api/turbo.json index 8d502f53d00..2f830fbcb8a 100644 --- a/api/turbo.json +++ b/api/turbo.json @@ -35,7 +35,8 @@ "SES_REGION", "SES_SECRET", "SHOW_UPCOMING_CHANGES", - "STRIPE_SECRET_KEY" + "STRIPE_SECRET_KEY", + "TPA_API_BEARER_TOKEN" ] }, "test": { @@ -72,7 +73,8 @@ "SES_REGION", "SES_SECRET", "SHOW_UPCOMING_CHANGES", - "STRIPE_SECRET_KEY" + "STRIPE_SECRET_KEY", + "TPA_API_BEARER_TOKEN" ] } } diff --git a/sample.env b/sample.env index cf7da4a55d9..7282de139ef 100644 --- a/sample.env +++ b/sample.env @@ -24,6 +24,9 @@ ALGOLIA_API_KEY=api_key_from_algolia_dashboard STRIPE_PUBLIC_KEY=pk_from_stripe_dashboard STRIPE_SECRET_KEY=sk_from_stripe_dashboard +# Third-party App API +TPA_API_BEARER_TOKEN=tpa_api_bearer_token_from_dashboard + # PayPal PAYPAL_CLIENT_ID=id_from_paypal_dashboard