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