Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
PORT=5000
NODE_ENV=development
MONGO_URI=mongodb://localhost:27017/sidewalk

# Choose either JWT_SECRET or the RS256 key pair.
JWT_SECRET=replace-with-a-long-random-string
# JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"

ACCESS_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_IN=30d

STELLAR_SECRET_KEY=replace-with-stellar-testnet-secret
REDIS_URL=redis://127.0.0.1:6379

RESEND_API_KEY=
OTP_EMAIL_FROM=no-reply@sidewalk.local

S3_BUCKET=sidewalk-dev
S3_REGION=us-east-1
S3_ENDPOINT=
S3_PUBLIC_BASE_URL=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

ENABLE_MEDIA_WORKER=true
ENABLE_STELLAR_ANCHOR_WORKER=true
6 changes: 3 additions & 3 deletions apps/api/src/config/db.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import mongoose from 'mongoose';
import { logger } from '../core/logging/logger';
import { getApiEnv } from './env';

export const connectDB = async () => {
try {
const conn = await mongoose.connect(
process.env.MONGO_URI || 'mongodb://localhost:27017/sidewalk',
);
const { MONGO_URI } = getApiEnv();
const conn = await mongoose.connect(MONGO_URI);
logger.info('MongoDB connected', { host: conn.connection.host });
} catch (error) {
logger.error('MongoDB connection failed', { error: (error as Error).message });
Expand Down
93 changes: 93 additions & 0 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { z } from 'zod';

const optionalTrimmedString = z
.string()
.trim()
.min(1)
.optional();

const apiEnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(5000),
MONGO_URI: z.string().trim().min(1).default('mongodb://localhost:27017/sidewalk'),
JWT_SECRET: optionalTrimmedString,
JWT_PRIVATE_KEY: optionalTrimmedString,
JWT_PUBLIC_KEY: optionalTrimmedString,
ACCESS_TOKEN_EXPIRES_IN: z.string().trim().min(1).default('15m'),
REFRESH_TOKEN_EXPIRES_IN: z.string().trim().min(1).default('30d'),
STELLAR_SECRET_KEY: optionalTrimmedString,
REDIS_URL: z.string().trim().url().optional(),
RESEND_API_KEY: optionalTrimmedString,
OTP_EMAIL_FROM: z.string().trim().min(1).default('no-reply@sidewalk.local'),
S3_BUCKET: optionalTrimmedString,
S3_REGION: z.string().trim().min(1).default('us-east-1'),
S3_ENDPOINT: z.string().trim().url().optional(),
S3_PUBLIC_BASE_URL: z.string().trim().url().optional(),
S3_ACCESS_KEY_ID: optionalTrimmedString,
S3_SECRET_ACCESS_KEY: optionalTrimmedString,
ENABLE_MEDIA_WORKER: z.enum(['true', 'false']).default('true'),
ENABLE_STELLAR_ANCHOR_WORKER: z.enum(['true', 'false']).default('true'),
});

const formatIssues = (issues: z.ZodIssue[]) =>
issues
.map((issue) => {
const path = issue.path.join('.') || 'env';
return `${path}: ${issue.message}`;
})
.join('; ');

const parseApiEnv = () => {
const result = apiEnvSchema.safeParse(process.env);
if (!result.success) {
throw new Error(`Invalid API environment configuration: ${formatIssues(result.error.issues)}`);
}

return result.data;
};

export const getApiEnv = () => parseApiEnv();

export const getJwtEnv = () => {
const env = getApiEnv();
const hasSecret = Boolean(env.JWT_SECRET);
const hasKeyPair = Boolean(env.JWT_PRIVATE_KEY && env.JWT_PUBLIC_KEY);

if (!hasSecret && !hasKeyPair) {
throw new Error(
'JWT configuration missing. Set JWT_SECRET or JWT_PRIVATE_KEY and JWT_PUBLIC_KEY.',
);
}

return env;
};

export const getStellarEnv = () => {
const env = getApiEnv();

if (!env.STELLAR_SECRET_KEY) {
throw new Error('STELLAR_SECRET_KEY is required for Stellar-backed API flows.');
}

return {
...env,
STELLAR_SECRET_KEY: env.STELLAR_SECRET_KEY,
};
};

export const getS3Env = () => {
const env = getApiEnv();

if (!env.S3_BUCKET || !env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {
throw new Error(
'S3 configuration missing. Set S3_BUCKET, S3_ACCESS_KEY_ID, and S3_SECRET_ACCESS_KEY.',
);
}

return {
...env,
S3_BUCKET: env.S3_BUCKET,
S3_ACCESS_KEY_ID: env.S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY: env.S3_SECRET_ACCESS_KEY,
};
};
9 changes: 3 additions & 6 deletions apps/api/src/config/stellar.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { StellarService } from '@sidewalk/stellar';

import dotenv from 'dotenv';
import { getStellarEnv } from './env';

dotenv.config();

const secret = process.env.STELLAR_SECRET_KEY;
const { STELLAR_SECRET_KEY } = getStellarEnv();

if (!secret) {
throw new Error('❌ Missing STELLAR_SECRET_KEY in .env file');
}

export const stellarService = new StellarService(secret);
export const stellarService = new StellarService(STELLAR_SECRET_KEY);
19 changes: 9 additions & 10 deletions apps/api/src/modules/auth/auth.jwt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import jwt from 'jsonwebtoken';
import { SignOptions } from 'jsonwebtoken';
import { AppError } from '../../core/errors/app-error';
import { getJwtEnv } from '../../config/env';
import { AuthenticatedUser, Role } from './auth.types';

type JwtAlgorithm = 'HS256' | 'RS256';
Expand All @@ -14,9 +15,10 @@ type JwtConfig = {
const toPem = (value: string) => value.replace(/\\n/g, '\n');

export const getJwtConfig = (): JwtConfig => {
const privateKey = process.env.JWT_PRIVATE_KEY;
const publicKey = process.env.JWT_PUBLIC_KEY;
const secret = process.env.JWT_SECRET;
const env = getJwtEnv();
const privateKey = env.JWT_PRIVATE_KEY;
const publicKey = env.JWT_PUBLIC_KEY;
const secret = env.JWT_SECRET;

if (privateKey && publicKey) {
return {
Expand Down Expand Up @@ -58,13 +60,9 @@ type RefreshPayload = {
tokenType: 'refresh';
};

const accessExpiresIn = (process.env.ACCESS_TOKEN_EXPIRES_IN ??
'15m') as SignOptions['expiresIn'];
const refreshExpiresIn = (process.env.REFRESH_TOKEN_EXPIRES_IN ??
'30d') as SignOptions['expiresIn'];

export const signAccessToken = (user: AuthenticatedUser): string => {
const config = getJwtConfig();
const { ACCESS_TOKEN_EXPIRES_IN } = getJwtEnv();
const payload: AccessPayload = {
sub: user.id,
role: user.role,
Expand All @@ -74,7 +72,7 @@ export const signAccessToken = (user: AuthenticatedUser): string => {

return jwt.sign(payload, config.signingKey, {
algorithm: config.algorithm,
expiresIn: accessExpiresIn,
expiresIn: ACCESS_TOKEN_EXPIRES_IN as SignOptions['expiresIn'],
});
};

Expand All @@ -85,6 +83,7 @@ export const signRefreshToken = (
tokenId: string,
): string => {
const config = getJwtConfig();
const { REFRESH_TOKEN_EXPIRES_IN } = getJwtEnv();
const payload: RefreshPayload = {
sub: user.id,
role: user.role,
Expand All @@ -97,7 +96,7 @@ export const signRefreshToken = (

return jwt.sign(payload, config.signingKey, {
algorithm: config.algorithm,
expiresIn: refreshExpiresIn,
expiresIn: REFRESH_TOKEN_EXPIRES_IN as SignOptions['expiresIn'],
});
};

Expand Down
12 changes: 6 additions & 6 deletions apps/api/src/modules/auth/otp.email.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { logger } from '../../core/logging/logger';

const resendApiKey = process.env.RESEND_API_KEY;
const otpFromEmail = process.env.OTP_EMAIL_FROM ?? 'no-reply@sidewalk.local';
import { getApiEnv } from '../../config/env';

export const sendOtpEmail = async (email: string, otpCode: string): Promise<void> => {
if (!resendApiKey) {
const { RESEND_API_KEY, OTP_EMAIL_FROM } = getApiEnv();

if (!RESEND_API_KEY) {
logger.warn('RESEND_API_KEY is missing, OTP email fallback to log output', {
email,
otpCode,
Expand All @@ -15,11 +15,11 @@ export const sendOtpEmail = async (email: string, otpCode: string): Promise<void
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${resendApiKey}`,
Authorization: `Bearer ${RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: otpFromEmail,
from: OTP_EMAIL_FROM,
to: [email],
subject: 'Your Sidewalk login code',
html: `<p>Your Sidewalk verification code is <strong>${otpCode}</strong>.</p><p>This code expires in 5 minutes.</p>`,
Expand Down
12 changes: 9 additions & 3 deletions apps/api/src/modules/auth/otp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createInitialSession } from './auth.tokens';
import { sendOtpEmail } from './otp.email';
import { otpStore } from './otp.store';
import { Role } from './auth.types';
import { ensureUserForOtpLogin } from '../users/users.service';

const OTP_TTL_SECONDS = 5 * 60;
const OTP_COOLDOWN_SECONDS = 60;
Expand Down Expand Up @@ -94,11 +95,16 @@ export const verifyOtpAndCreateSession = async (params: {
await otpStore.clearOtp(email);

const role = params.role ?? 'CITIZEN';
const user = await ensureUserForOtpLogin({
email,
role,
district: params.district,
});
const session = await createInitialSession(
{
id: email,
role,
district: params.district,
id: String(user._id),
role: user.role,
district: user.district,
},
params.deviceId,
);
Expand Down
Loading
Loading