From 7731340c7a7b7697b06efe8657476c7aabd3058e Mon Sep 17 00:00:00 2001 From: chemicalcommand Date: Wed, 25 Mar 2026 14:12:00 +0100 Subject: [PATCH] feat: add runtime env validation and otp user persistence --- README.md | 9 +- apps/api/.env.example | 27 ++++++ apps/api/src/config/db.ts | 6 +- apps/api/src/config/env.ts | 93 +++++++++++++++++++ apps/api/src/config/stellar.ts | 9 +- apps/api/src/modules/auth/auth.jwt.ts | 19 ++-- apps/api/src/modules/auth/otp.email.ts | 12 +-- apps/api/src/modules/auth/otp.service.ts | 12 ++- apps/api/src/modules/media/media.s3.ts | 74 ++++++++------- .../users/users.service.integration.test.ts | 80 ++++++++++++++++ apps/api/src/modules/users/users.service.ts | 52 +++++++++++ apps/api/src/server.ts | 8 +- apps/mobile/.env.example | 4 + apps/mobile/README.md | 54 ++++------- apps/mobile/app/index.tsx | 18 ++-- apps/mobile/package.json | 1 + apps/mobile/src/config/env.ts | 18 ++++ pnpm-lock.yaml | 47 +++------- 18 files changed, 400 insertions(+), 143 deletions(-) create mode 100644 apps/api/.env.example create mode 100644 apps/api/src/config/env.ts create mode 100644 apps/api/src/modules/users/users.service.integration.test.ts create mode 100644 apps/api/src/modules/users/users.service.ts create mode 100644 apps/mobile/.env.example create mode 100644 apps/mobile/src/config/env.ts diff --git a/README.md b/README.md index f3313bc..6e170c8 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ pnpm -r build ### 4. Configure Environment -Set up the environment variables for the API. +Set up the environment variables for the API and mobile app. ```bash # Create the .env file in the API folder @@ -139,8 +139,15 @@ cp apps/api/.env.example apps/api/.env # Update apps/api/.env with your MongoDB URI (defaults to local) # MONGO_URI=mongodb://localhost:27017/sidewalk +# Mobile can optionally read its API base URL from Expo public env +cp apps/mobile/.env.example apps/mobile/.env +# EXPO_PUBLIC_API_URL=http://localhost:5000 + ``` +The API now validates its runtime configuration through `apps/api/src/config/env.ts`. +Module-specific config fails with actionable messages when JWT, Stellar, or S3 settings are missing. + --- ## 🏃‍♂️ Running the Project diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..4d6be1c --- /dev/null +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/src/config/db.ts b/apps/api/src/config/db.ts index 1a81af6..ae1cea1 100644 --- a/apps/api/src/config/db.ts +++ b/apps/api/src/config/db.ts @@ -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 }); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..8ab27b4 --- /dev/null +++ b/apps/api/src/config/env.ts @@ -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, + }; +}; diff --git a/apps/api/src/config/stellar.ts b/apps/api/src/config/stellar.ts index a663a2a..d50bff8 100644 --- a/apps/api/src/config/stellar.ts +++ b/apps/api/src/config/stellar.ts @@ -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); diff --git a/apps/api/src/modules/auth/auth.jwt.ts b/apps/api/src/modules/auth/auth.jwt.ts index ffdef44..5922ea5 100644 --- a/apps/api/src/modules/auth/auth.jwt.ts +++ b/apps/api/src/modules/auth/auth.jwt.ts @@ -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'; @@ -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 { @@ -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, @@ -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'], }); }; @@ -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, @@ -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'], }); }; diff --git a/apps/api/src/modules/auth/otp.email.ts b/apps/api/src/modules/auth/otp.email.ts index 9125df1..79e2e24 100644 --- a/apps/api/src/modules/auth/otp.email.ts +++ b/apps/api/src/modules/auth/otp.email.ts @@ -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 => { - 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, @@ -15,11 +15,11 @@ export const sendOtpEmail = async (email: string, otpCode: string): PromiseYour Sidewalk verification code is ${otpCode}.

This code expires in 5 minutes.

`, diff --git a/apps/api/src/modules/auth/otp.service.ts b/apps/api/src/modules/auth/otp.service.ts index a25305c..607d3df 100644 --- a/apps/api/src/modules/auth/otp.service.ts +++ b/apps/api/src/modules/auth/otp.service.ts @@ -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; @@ -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, ); diff --git a/apps/api/src/modules/media/media.s3.ts b/apps/api/src/modules/media/media.s3.ts index 4779321..1a64565 100644 --- a/apps/api/src/modules/media/media.s3.ts +++ b/apps/api/src/modules/media/media.s3.ts @@ -12,33 +12,33 @@ import { import { Readable } from 'stream'; import sharp from 'sharp'; import { AppError } from '../../core/errors/app-error'; +import { getApiEnv, getS3Env } from '../../config/env'; -const bucket = process.env.S3_BUCKET; -const region = process.env.S3_REGION ?? 'us-east-1'; -const endpoint = process.env.S3_ENDPOINT; -const accessKeyId = process.env.S3_ACCESS_KEY_ID; -const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY; - -if (!bucket) { - throw new AppError('Missing S3_BUCKET environment variable', 500, 'S3_CONFIG_MISSING'); -} - -if (!accessKeyId || !secretAccessKey) { - throw new AppError( - 'Missing S3_ACCESS_KEY_ID or S3_SECRET_ACCESS_KEY environment variable', - 500, - 'S3_CONFIG_MISSING', - ); -} +const getS3ClientConfig = () => { + const { S3_BUCKET, S3_REGION, S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY } = + getS3Env(); + + return { + bucket: S3_BUCKET, + region: S3_REGION, + endpoint: S3_ENDPOINT, + accessKeyId: S3_ACCESS_KEY_ID, + secretAccessKey: S3_SECRET_ACCESS_KEY, + }; +}; + +const getS3Client = () => { + const { region, endpoint, accessKeyId, secretAccessKey } = getS3ClientConfig(); -const client = new S3Client({ - region, - ...(endpoint ? { endpoint, forcePathStyle: true } : {}), - credentials: { - accessKeyId, - secretAccessKey, - }, -}); + return new S3Client({ + region, + ...(endpoint ? { endpoint, forcePathStyle: true } : {}), + credentials: { + accessKeyId, + secretAccessKey, + }, + }); +}; const extensionForMime = (mime: string) => { if (mime === 'image/jpeg') return '.jpg'; @@ -48,17 +48,17 @@ const extensionForMime = (mime: string) => { }; export const buildObjectUrl = (objectKey: string) => { - const publicBase = process.env.S3_PUBLIC_BASE_URL; - if (publicBase) { - return `${publicBase.replace(/\/+$/, '')}/${objectKey}`; + const { S3_PUBLIC_BASE_URL, S3_BUCKET, S3_REGION, S3_ENDPOINT } = getApiEnv(); + if (S3_PUBLIC_BASE_URL) { + return `${S3_PUBLIC_BASE_URL.replace(/\/+$/, '')}/${objectKey}`; } - if (endpoint) { - const normalized = endpoint.replace(/\/+$/, ''); - return `${normalized}/${bucket}/${objectKey}`; + if (S3_ENDPOINT) { + const normalized = S3_ENDPOINT.replace(/\/+$/, ''); + return `${normalized}/${S3_BUCKET}/${objectKey}`; } - return `https://${bucket}.s3.${region}.amazonaws.com/${objectKey}`; + return `https://${S3_BUCKET}.s3.${S3_REGION}.amazonaws.com/${objectKey}`; }; export const buildObjectKey = (mime: string) => { @@ -71,6 +71,8 @@ export const uploadStreamToS3 = async ( mime: string, objectKey: string, ) => { + const client = getS3Client(); + const { bucket } = getS3ClientConfig(); const params: PutObjectCommandInput = { Bucket: bucket, Key: objectKey, @@ -100,6 +102,8 @@ const toBuffer = async (stream: Readable): Promise => { }; export const compressAndReplaceImage = async (objectKey: string): Promise => { + const client = getS3Client(); + const { bucket } = getS3ClientConfig(); const response = await client.send( new GetObjectCommand({ Bucket: bucket, @@ -138,6 +142,8 @@ export const generatePresignedGetObjectUrl = async ( objectKey: string, expiresInSeconds = 900, ): Promise => { + const client = getS3Client(); + const { bucket } = getS3ClientConfig(); return getSignedUrl( client as never, new GetObjectCommand({ @@ -155,6 +161,8 @@ export type S3ListedObject = { }; export const listAllReportObjects = async (): Promise => { + const client = getS3Client(); + const { bucket } = getS3ClientConfig(); const results: S3ListedObject[] = []; let continuationToken: string | undefined; @@ -186,6 +194,8 @@ export const listAllReportObjects = async (): Promise => { }; export const deleteObjectFromS3 = async (objectKey: string): Promise => { + const client = getS3Client(); + const { bucket } = getS3ClientConfig(); await client.send( new DeleteObjectCommand({ Bucket: bucket, diff --git a/apps/api/src/modules/users/users.service.integration.test.ts b/apps/api/src/modules/users/users.service.integration.test.ts new file mode 100644 index 0000000..f7e69de --- /dev/null +++ b/apps/api/src/modules/users/users.service.integration.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { UserModel } from './user.model'; +import { ensureUserForOtpLogin } from './users.service'; + +test('ensureUserForOtpLogin creates a normalized user when none exists', async () => { + const originalFindOne = UserModel.findOne; + const originalFindOneAndUpdate = UserModel.findOneAndUpdate; + + try { + let capturedEmail: string | undefined; + + UserModel.findOne = (async ({ email }: { email: string }) => { + capturedEmail = email; + return null; + }) as unknown as typeof UserModel.findOne; + + UserModel.findOneAndUpdate = (async ( + _filter: unknown, + update: { $set: { email: string; role: string; district?: string } }, + ) => + ({ + _id: '507f1f77bcf86cd799439011', + email: update.$set.email, + role: update.$set.role, + district: update.$set.district, + }) as never) as unknown as typeof UserModel.findOneAndUpdate; + + const user = await ensureUserForOtpLogin({ + email: 'Test@Example.com', + role: 'CITIZEN', + district: 'ikeja', + }); + + assert.equal(capturedEmail, 'test@example.com'); + assert.equal(String(user._id), '507f1f77bcf86cd799439011'); + assert.equal(user.email, 'test@example.com'); + assert.equal(user.role, 'CITIZEN'); + assert.equal(user.district, 'ikeja'); + } finally { + UserModel.findOne = originalFindOne; + UserModel.findOneAndUpdate = originalFindOneAndUpdate; + } +}); + +test('ensureUserForOtpLogin preserves existing role and district when not overridden', async () => { + const originalFindOne = UserModel.findOne; + const originalFindOneAndUpdate = UserModel.findOneAndUpdate; + + try { + UserModel.findOne = (async () => + ({ + role: 'AGENCY_ADMIN', + district: 'surulere', + }) as never) as unknown as typeof UserModel.findOne; + + UserModel.findOneAndUpdate = (async ( + _filter: unknown, + update: { $set: { email: string; role: string; district?: string } }, + ) => + ({ + _id: '507f191e810c19729de860ea', + email: update.$set.email, + role: update.$set.role, + district: update.$set.district, + }) as never) as unknown as typeof UserModel.findOneAndUpdate; + + const user = await ensureUserForOtpLogin({ + email: 'admin@example.com', + role: 'CITIZEN', + }); + + assert.equal(String(user._id), '507f191e810c19729de860ea'); + assert.equal(user.role, 'AGENCY_ADMIN'); + assert.equal(user.district, 'surulere'); + } finally { + UserModel.findOne = originalFindOne; + UserModel.findOneAndUpdate = originalFindOneAndUpdate; + } +}); diff --git a/apps/api/src/modules/users/users.service.ts b/apps/api/src/modules/users/users.service.ts new file mode 100644 index 0000000..ba30272 --- /dev/null +++ b/apps/api/src/modules/users/users.service.ts @@ -0,0 +1,52 @@ +import { UserModel, type UserDocument, type UserRole } from './user.model'; + +type EnsureOtpUserParams = { + email: string; + role: UserRole; + district?: string; +}; + +export const ensureUserForOtpLogin = async ({ + email, + role, + district, +}: EnsureOtpUserParams): Promise => { + const normalizedEmail = email.trim().toLowerCase(); + const existingUser = await UserModel.findOne({ email: normalizedEmail }); + + const effectiveRole = existingUser?.role ?? role; + const effectiveDistrict = district ?? existingUser?.district; + + const update: { + $set: { + email: string; + role: UserRole; + district?: string; + }; + $setOnInsert: { + reputationScore: number; + }; + } = { + $set: { + email: normalizedEmail, + role: effectiveRole, + }, + $setOnInsert: { + reputationScore: 50, + }, + }; + + if (effectiveDistrict) { + update.$set.district = effectiveDistrict; + } + + return UserModel.findOneAndUpdate( + { email: normalizedEmail }, + update, + { + upsert: true, + new: true, + setDefaultsOnInsert: true, + }, + ); +}; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 0dc6186..d62b656 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -2,6 +2,7 @@ import express from "express"; import cors from "cors"; import dotenv from "dotenv"; import { connectDB } from "./config/db"; +import { getApiEnv } from "./config/env"; import { getLiveness, getReadiness } from "./modules/health/health.controller"; import { stellarService } from "./config/stellar"; import reportsRoutes from "./modules/reports/reports.routes"; @@ -21,7 +22,8 @@ import { tieredApiRateLimiter } from "./core/rate-limit/rate-limit.middleware"; dotenv.config(); const app = express(); -const PORT = process.env.PORT || 5000; +const env = getApiEnv(); +const PORT = env.PORT; app.set("trust proxy", 1); @@ -47,14 +49,14 @@ const startServer = async () => { logger.info("Initializing Stellar service"); await stellarService.ensureFunded(); - if (process.env.ENABLE_MEDIA_WORKER !== "false") { + if (env.ENABLE_MEDIA_WORKER !== "false") { startMediaProcessingWorker(); startMediaCleanupWorker(); await ensureMediaCleanupSchedule(); logger.info("Media workers initialized (processing + orphan cleanup)"); } - if (process.env.ENABLE_STELLAR_ANCHOR_WORKER !== "false") { + if (env.ENABLE_STELLAR_ANCHOR_WORKER !== "false") { startStellarAnchorWorker(); logger.info("Stellar anchor worker initialized"); } diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000..e192527 --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,4 @@ +EXPO_PUBLIC_API_URL=http://localhost:5000 + +# Reserved for future web parity if a Next.js client is added to main. +# NEXT_PUBLIC_API_URL=http://localhost:5000 diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 48dd63f..17a0d46 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -1,50 +1,30 @@ -# Welcome to your Expo app 👋 +# Sidewalk Mobile -This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). +Expo React Native app for the Sidewalk monorepo. -## Get started +## Environment -1. Install dependencies - - ```bash - npm install - ``` - -2. Start the app - - ```bash - npx expo start - ``` - -In the output, you'll find options to open the app in a - -- [development build](https://docs.expo.dev/develop/development-builds/introduction/) -- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) -- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) -- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo - -You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). - -## Get a fresh project - -When you're ready, run: +Copy the example file and point the app at the API you want to test against: ```bash -npm run reset-project +cp .env.example .env ``` -This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. +Supported variables: -## Learn more +- `EXPO_PUBLIC_API_URL`: base URL for the Sidewalk API, defaults to `http://localhost:5000` -To learn more about developing your project with Expo, look at the following resources: +The app validates this value at runtime and falls back to the local API default if the URL is invalid. -- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). -- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. +## Run -## Join the community +```bash +pnpm --filter mobile start +``` -Join our community of developers creating universal apps. +## Checks -- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. -- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. +```bash +pnpm --filter mobile lint +pnpm --filter mobile typecheck +``` diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 703397b..79dcfdc 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,26 +1,32 @@ import { StatusBar } from 'expo-status-bar'; import { useEffect, useState } from 'react'; import { StyleSheet, Text, View, ActivityIndicator } from 'react-native'; +import { getMobileEnv } from '../src/config/env'; export default function App() { const [status, setStatus] = useState('Checking...'); const [loading, setLoading] = useState(true); - - const API_URL = 'http://localhost:5001/api/health'; + const { apiUrl } = getMobileEnv(); + const healthEndpoint = `${apiUrl}/api/health`; useEffect(() => { - fetch(API_URL) + fetch(healthEndpoint) .then((res) => res.json()) .then((data) => { - setStatus(`API: ${data.status}\nStellar: ${data.stellar_connected}`); + const integrations = data.integrations + ? Object.entries(data.integrations) + .map(([name, value]) => `${name}: ${String(value)}`) + .join('\n') + : 'No integration details'; + setStatus(`API: ${data.status}\n${integrations}`); setLoading(false); }) .catch((err) => { - setStatus('Error connecting to API'); + setStatus(`Error connecting to API\n${healthEndpoint}`); console.error(err); setLoading(false); }); - }, []); + }, [healthEndpoint]); return ( diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b271403..5e7d813 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -39,6 +39,7 @@ "react-native-web": "~0.21.0" }, "devDependencies": { + "@types/node": "^22.10.7", "@types/react": "~19.1.0", "typescript": "~5.9.2", "eslint": "^9.25.0", diff --git a/apps/mobile/src/config/env.ts b/apps/mobile/src/config/env.ts new file mode 100644 index 0000000..469f462 --- /dev/null +++ b/apps/mobile/src/config/env.ts @@ -0,0 +1,18 @@ +const defaultApiUrl = 'http://localhost:5000'; + +const normalizeApiUrl = (value: string) => value.replace(/\/+$/, ''); + +export const getMobileEnv = () => { + const rawApiUrl = process.env.EXPO_PUBLIC_API_URL ?? defaultApiUrl; + + try { + const parsed = new URL(rawApiUrl); + return { + apiUrl: normalizeApiUrl(parsed.toString()), + }; + } catch { + return { + apiUrl: defaultApiUrl, + }; + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c93111c..e5cc7fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: specifier: 0.5.1 version: 0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) devDependencies: + '@types/node': + specifier: ^22.10.7 + version: 22.19.11 '@types/react': specifier: ~19.1.0 version: 19.1.17 @@ -1318,105 +1321,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2470,49 +2457,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4191,28 +4170,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -9856,9 +9831,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3) '@typescript-eslint/parser': 8.56.0(eslint@9.39.3)(typescript@5.9.3) eslint: 9.39.3 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3) eslint-plugin-expo: 1.0.0(eslint@9.39.3)(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3))(eslint@9.39.3) eslint-plugin-react: 7.37.5(eslint@9.39.3) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.3) globals: 16.5.0 @@ -9876,7 +9851,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@5.5.0) @@ -9887,18 +9862,18 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3))(eslint@9.39.3) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3))(eslint@9.39.3): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.56.0(eslint@9.39.3)(typescript@5.9.3) eslint: 9.39.3 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3) transitivePeerDependencies: - supports-color @@ -9911,7 +9886,7 @@ snapshots: - supports-color - typescript - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3))(eslint@9.39.3): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9922,7 +9897,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.3 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3))(eslint@9.39.3) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3