refactor: replace with service-to-service token

This commit is contained in:
Mrugesh Mohapatra
2026-02-25 18:28:52 +05:30
parent 98d7abd599
commit 3b10fa50ff
13 changed files with 567 additions and 336 deletions

View File

@@ -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);
}

View File

@@ -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<typeof import('../utils/env.js')>();
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' });
});
});

View File

@@ -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<void>;
}
}
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' });

View File

@@ -0,0 +1,293 @@
import { describe, test, expect, afterEach, vi } from 'vitest';
vi.mock('../../utils/env', async importOriginal => {
const actual = await importOriginal<typeof import('../../utils/env.js')>();
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<string, Array<Record<string, unknown>>>;
};
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'
});
});
});
});
});

View File

@@ -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();
};

View File

@@ -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<typeof createSuperRequest>;
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);
});
});
});

View File

@@ -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<string, NormalizedChallenge[]> = {};
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();
};

View File

@@ -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';

View File

@@ -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';

View File

@@ -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() })
}
};

View File

@@ -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) {

View File

@@ -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"
]
}
}

View File

@@ -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