This document outlines the validation system for Stellar Goal Vault, covering both frontend form validation and backend API validation using Zod schemas. The system provides immediate user feedback on the frontend and robust request validation on the backend.
The validation system operates at two layers:
- Provides immediate user feedback during form input
- Mirrors backend validation rules for consistency
- Prevents invalid API calls by catching errors client-side
- Uses custom validation functions with regex patterns
- Uses Zod schemas for robust request validation
- Applied via
validateBodymiddleware on API endpoints - Includes SSRF protection for URL fields
- Stellar address validation with CRC checksum verification
- Query parameter parsing and validation
Located in backend/src/validation/schemas.ts, these schemas define the validation rules for all API endpoints.
export const STELLAR_ACCOUNT_REGEX = /^G[A-Z2-7]{55}$/;
export const ASSET_CODE_REGEX = /^[A-Za-z0-9]{1,12}$/;
export const CAMPAIGN_ID_REGEX = /^[1-9]\d*$/;
export const TX_HASH_REGEX = /^[A-Fa-f0-9]{64}$/;- Validates Stellar public key format (56 chars, starts with G)
- Uses regex pattern matching
- Error: "Must be a valid Stellar account ID (starts with G and is exactly 56 characters)"
- Validates asset codes (1-12 alphanumeric characters)
- Transforms to uppercase
- Refines against
config.allowedAssetslist - Error: "Asset code is not supported. Supported assets: X, Y, Z"
- Coerces to number
- Must be finite and positive
- Error: "Amount must be greater than zero"
- Coerces to number
- Must be positive integer
- Error: "deadline must be a valid UNIX timestamp in seconds"
- Enforces HTTPS-only protocol
- Blocks private/loopback IP addresses
- Rejects URLs with userinfo (username/password)
- Max length: 2048 characters
- Part of SSRF protection (see
urlSafety.ts)
{
creator: stellarAccountIdSchema,
title: z.string().trim().min(4).max(80),
description: z.string().trim(). min(20).max(500),
acceptedTokens: z.array(assetCodeSchema).min(1),
targetAmount: positiveAmountSchema,
deadline: unixTimestampSchema,
metadata: {
imageUrl: httpsOnlyUrlSchema.optional(),
externalLink: httpsOnlyUrlSchema.optional()
}.optional(),
maxPerContributor: optionalPositiveIntSchema
}{
contributor: stellarAccountIdSchema,
amount: positiveAmountSchema,
assetCode: assetCodeSchema
}{
contributor: stellarAccountIdSchema,
amount: positiveAmountSchema,
assetCode: assetCodeSchema,
transactionHash: z.string().regex(TX_HASH_REGEX),
confirmedAt: unixTimestampSchema.optional()
}{
creator: stellarAccountIdSchema,
transactionHash: z.string().regex(TX_HASH_REGEX),
confirmedAt: unixTimestampSchema.optional()
}{
contributor: stellarAccountIdSchema,
soroban: {
txHash: stellarTransactionHashSchema,
contractId: z.string().min(1),
networkPassphrase: z.string().min(1),
rpcUrl: z.string().url(),
walletAddress: stellarAccountIdSchema,
ledger: z.coerce.number().int().positive().optional(),
createdAt: unixTimestampSchema.optional(),
latestLedger: z.coerce.number().int().positive().optional()
}
}- Validates
pageandlimitquery parameters - Both must be provided together or omitted together
page: positive integerlimit: integer from 1 to 100- Returns
{ ok: true, page?, limit? }or{ ok: false, issues }
- Validates
pageandpageSizefor campaign history - Defaults: page=1, pageSize=20
pageSizemax: 100
- Validates
pageandlimitfor pledge lists - Defaults: page=1, limit=10
limitmax: 100
Located in frontend/src/utils/validation.ts, these functions mirror backend rules for client-side validation.
- Checks format: 56 characters, starts with 'G', A-Z2-7 only
- Error: "Invalid Stellar account format (must contain only A-Z and 2-7)"
- Length: 4-80 characters
- Error: "Title must be between 4 and 80 characters"
- Length: 20-500 characters
- Error: "Description must be between 20 and 500 characters"
- Must be valid number
- Must be greater than zero
- Must be at least 0.01
- Errors: "Amount must be a valid number", "Amount must be greater than zero", "Amount must be at least 0.01"
- Must be whole number
- Must be at least 1 hour
- Must not exceed 8760 hours (365 days)
- Errors: "Deadline must be a whole number", "Deadline must be at least 1 hour", "Deadline cannot exceed 365 days"
- Batch validates all form fields
- Returns object with field names as keys and error messages as values
- Checks if any errors exist in the errors object
- Replicates
StrKey.isValidEd25519PublicKeyfrom Stellar SDK - Validates Base32 encoding with CRC-16/XModem checksum
- Checks version byte (0x30 for Ed25519 public key)
- Function:
isValidStellarPublicKey(address: string)
- Two-layer defense against Server-Side Request Forgery
- Layer 1:
httpsOnlyUrlSchema- synchronous schema validation- Rejects non-HTTPS protocols
- Blocks private/loopback IP literals
- Rejects URLs with userinfo
- Layer 2:
assertSafeRemoteUrl- runtime DNS resolution- Resolves hostnames and checks against private CIDRs
- Defends against DNS rebinding attacks
- Blocked ranges include: 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, and IPv6 equivalents
Express middleware that validates request bodies against Zod schemas:
import { validateBody } from './middleware/validateBody';
import { createCampaignPayloadSchema } from './validation/schemas';
app.post('/api/campaigns', validateBody(createCampaignPayloadSchema), handler);- Uses
safeParseAsyncfor async schema support - Replaces
req.bodywith parsed/transformed data on success - Returns 400 with
{ error: 'Validation failed', details: ZodIssue[] }on failure - Designed for POST/PATCH routes with JSON payloads
- Implementation: The creator account field now validates against Stellar address format (56 characters starting with 'G', containing only A-Z and 2-7)
- User Experience: Error messages appear below the field as the user types
- Example Error: "Invalid Stellar account format (must contain only A-Z and 2-7)"
-
Amount Field validates:
- Value is a valid number
- Amount is greater than zero
- Amount is at least 0.01 (minimum)
- Example: "Amount must be at least 0.01"
-
Deadline Field validates:
- Value is a whole number
- Hours is at least 1
- Hours does not exceed 365 days (8760 hours)
- Example: "Deadline cannot exceed 365 days"
- Implementation: Submit button is disabled when:
- Form has any validation errors
- Form is currently submitting
- Visual Feedback: The button shows reduced opacity and cursor changes to "not-allowed"
- Color Scheme: Uses red/error color (#f87171) consistent with the design system
- Field Styling:
- Red border around fields with errors
- Subtle red background (rgba(127, 29, 29, 0.1))
- Red glow on focus to maintain visual consistency
- Error Message Text:
- Font size: 0.8125rem (smaller than field label but visible)
- Color: #f87171
- Font weight: 500 (medium weight for emphasis)
- Positioned below field with 6px margin
Add your schema to backend/src/validation/schemas.ts:
export const newEndpointPayloadSchema = z.object({
// Use existing reusable schemas where possible
accountId: stellarAccountIdSchema,
amount: positiveAmountSchema,
// Or define custom validation
customField: z
.string()
.trim()
.min(1, 'Custom field is required')
.max(100, 'Custom field must not exceed 100 characters'),
// For optional fields
optionalField: z.string().optional(),
// For URLs with SSRF protection
userUrl: httpsOnlyUrlSchema.optional(),
});In backend/src/index.ts, add the validateBody middleware to your route:
import { validateBody } from './middleware/validateBody';
import { newEndpointPayloadSchema } from './validation/schemas';
app.post(
'/api/new-endpoint',
validateBody(newEndpointPayloadSchema),
(req: Request, res: Response) => {
// req.body is now typed and validated
const body = req.body as z.infer<typeof newEndpointPayloadSchema>;
// Your handler logic here
}
);If the endpoint has a corresponding form, add validation to frontend/src/utils/validation.ts:
export function validateCustomField(value: string): string | undefined {
if (!value || value.trim().length === 0) {
return 'Custom field is required';
}
if (value.length > 100) {
return 'Custom field must not exceed 100 characters';
}
return undefined;
}Create tests in backend/src/validation/schemas.test.ts:
describe('newEndpointPayloadSchema', () => {
it('accepts valid payload', () => {
const result = newEndpointPayloadSchema.safeParse({
accountId: 'GABCD...',
amount: 100,
customField: 'valid value',
});
expect(result.success).toBe(true);
});
it('rejects invalid Stellar account', () => {
const result = newEndpointPayloadSchema.safeParse({
accountId: 'invalid',
amount: 100,
customField: 'valid value',
});
expect(result.success).toBe(false);
});
});If using OpenAPI, the schema will automatically be documented via extendZodWithOpenApi(z) at the top of schemas.ts.
-
Reuse Existing Schemas: Always use
stellarAccountIdSchema,positiveAmountSchema, etc. instead of redefining common patterns. -
Coerce Types: Use
z.coerce.number()for numeric fields to handle string inputs from forms. -
Trim Strings: Always use
.trim()on string fields to prevent whitespace-related issues. -
SSRF Protection: For any user-supplied URLs, use
httpsOnlyUrlSchemaand pair withassertSafeRemoteUrlat fetch time. -
Error Messages: Provide clear, actionable error messages that help users fix validation issues.
-
Query Parameters: Use the existing parsing functions (
parseCampaignListPaginationQuery, etc.) as templates for new query parameter validators. -
Test Coverage: Always add tests for both valid and invalid inputs, including boundary conditions.
-
frontend/src/utils/validation.ts- Core validation utilities mirroring backend schema rules
- Provides individual validation functions for each field
- Includes batch validation for entire form
- All error messages are user-friendly and specific
-
frontend/src/utils/validation.test.ts- Comprehensive unit tests for all validation functions
- Tests both valid and invalid inputs
- Tests boundary conditions (min/max values)
- ~50 test cases covering all scenarios
-
frontend/src/components/CreateCampaignForm.validation.test.tsx- Integration tests for form UI validation behavior
- Tests error display, button state, and styling
- Tests real-time validation feedback
- Tests form submission flow
-
frontend/src/components/CreateCampaignForm.tsx- Added validation state management
- Integrated real-time validation on field changes
- Added inline error display under each field
- Applied error CSS classes to invalid fields
- Disabled submit button when form is invalid
-
frontend/src/index.css- Added
.input-errorclass for field styling - Added
.field-errorclass for error message styling - Error states use red color (#f87171) with transparent background
- Added
- Required: Yes
- Format: Must match
^G[A-Z2-7]{55}$(56 characters total) - Validations:
- Not empty
- Exactly 56 characters
- Starts with 'G'
- Contains only A-Z and 2-7
- Required: Yes
- Length: 4-80 characters
- Validations:
- Not empty
- Minimum 4 characters
- Maximum 80 characters
- Required: Yes
- Length: 20-500 characters
- Validations:
- Not empty
- Minimum 20 characters
- Maximum 500 characters
- Required: Yes
- Type: Number
- Validations:
- Valid number (no text)
- Greater than zero
- Minimum 0.01
- Uses HTML
type="number"withstep="0.01"andmin="0.01"
- Required: Yes
- Type: Integer
- Validations:
- Valid whole number (no decimals)
- At least 1 hour
- Maximum 8760 hours (365 days)
- Uses HTML
type="number"withstep="1"andmin="1"
- Default: First allowed asset
- Validation: No client-side validation (backend validates against allowed list)
- Validation: HTML5 URL validation via
type="url"attribute - Not validated on submit (optional fields are skipped in batch validation)
-
On Field Change (Real-time):
function update(field, value) { setValues({ ...values, [field]: value }); const newErrors = validateForm(updatedValues); setValidationErrors(newErrors); }
- Validates entire form after each field change
- Provides immediate feedback to user
- Error messages appear/disappear as user types
-
On Form Submit:
async function handleSubmit(event) { const errors = validateForm(values); setValidationErrors(errors); if (!isFormValid(errors)) return; // Only proceed if no errors }
- Validates before submission
- Prevents API call if validation fails
- User can correct errors and retry
-
Error Display:
{validationErrors.creator ? ( <span className="field-error">{validationErrors.creator}</span> ) : null}
- Conditionally renders error message below field
- Only shows when field has error
- Message is specific to the validation rule that failed
const [validationErrors, setValidationErrors] = useState<FormErrors>({});FormErrorstype maps field names to error messages (or undefined)- Empty object
{}means no errors isFormValid(errors)checks if any error exists
- User focuses on Creator Account field
- User types "invalid_address"
- Realtime validation triggers:
- "Invalid Stellar account format (must contain only A-Z and 2-7)" error appears
- Submit button becomes disabled
- User corrects the address to valid format (e.g., "G" + valid characters)
- Error disappears immediately
- Submit button becomes enabled
- User fills form with some invalid fields
- User clicks "Create campaign" button
- Validation runs again
- All validation errors are displayed together
- Form remains on page, allowing corrections
- User fixes errors
- User can now submit successfully
- User focuses on Target Amount field
- User types "0"
- "Amount must be greater than zero" appears
- User changes to "0.001"
- "Amount must be at least 0.01" appears
- User changes to "100"
- Error disappears, field is valid
- Class:
.input-error - Applied to:
<input>or<textarea>elements - Border: #f87171 (red)
- Background: rgba(127, 29, 29, 0.1) (dark red transparent)
- Focus state: Maintains red border with matching focus glow
- Class:
.field-error - Font size: 0.8125rem
- Color: #f87171 (red)
- Font weight: 500
- Margin: 6px top margin
- Line height: 1.4 for readability
Located in frontend/src/utils/validation.ts:
export const STELLAR_ACCOUNT_REGEX = /^G[A-Z2-7]{55}$/;
export const MIN_TITLE_LENGTH = 4;
export const MAX_TITLE_LENGTH = 80;
export const MIN_DESCRIPTION_LENGTH = 20;
export const MAX_DESCRIPTION_LENGTH = 500;
export const MIN_TARGET_AMOUNT = 0.01;
export const MIN_DEADLINE_HOURS = 1;These constants are maintained in sync with backend validation rules.
- Form would submit immediately on button click
- Backend would return errors
- User would see server error messages
- Client validates immediately before sending request
- Reduces unnecessary API calls for invalid data
- Server-side validation still acts as final safeguard
- Better user experience with real-time feedback
- Uses HTML5 input attributes (
type="number",min,step) - Uses flexbox and modern CSS (already required by app)
- Regular expressions for format validation
- Compatible with all modern browsers (Chrome, Firefox, Safari, Edge)
- Tests each validation function independently
- Tests boundary conditions
- Tests error message content
- 50+ test cases
- Tests error display in UI
- Tests button disabled state
- Tests CSS class application
- Tests real-time validation
- Tests form submission flow
- Test each field with valid and invalid inputs
- Test rapid field changes to ensure real-time validation
- Test submit button state transitions
- Test error message visibility
- Test form reset after successful submission
Potential improvements for future iterations:
- Field-specific validation debouncing (for performance)
- Character count display for title/description fields
- Password strength indicator pattern (if needed)
- Async validation for unique campaign titles
- Accessibility improvements (aria-invalid, aria-describedby)
- Animated error transitions
- Toast notifications for submission success/failure
This implementation provides a robust, user-friendly validation system that:
- ✅ Validates all required fields with specific, clear error messages
- ✅ Provides real-time feedback as users type
- ✅ Prevents invalid submissions with disabled button
- ✅ Maintains visual consistency with the design system
- ✅ Reduces unnecessary API calls for invalid data
- ✅ Improves overall user experience significantly
The validation rules are mirrored from the backend schema, ensuring client and server validation consistency.