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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ graph TB
- [Architecture Decision Records (ADRs)](docs/adr/README.md) — Technical decisions and rationale
- [Security Best Practices Guide](docs/security.md) — Security guidelines, audit checklist, and incident response
- [Performance Benchmarks](docs/performance.md) — Gas costs, API latency, frontend metrics
- [CDN Configuration](docs/cdn.md) — Serve frontend assets and uploaded media through Cloudflare
- [Troubleshooting Guide](docs/troubleshooting.md) — Common issues and solutions
- [Multi-Region Deployment](docs/multi-region-deployment.md) — Deployment strategy and failover
- [Kubernetes Deployment](docs/kubernetes-deployment.md) — Kubernetes manifests, scaling, and self-healing
Expand Down Expand Up @@ -185,6 +186,8 @@ VITE_CONTRACT_ID=<YOUR_CONTRACT_ID>
VITE_RPC_URL=https://soroban-testnet.stellar.org:443
VITE_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
VITE_API_URL=http://localhost:3001
# Optional production CDN for built frontend assets
# VITE_CDN_URL=https://cdn.example.com
```

**Backend** — copy and fill in `backend/.env.example` as `backend/.env`:
Expand All @@ -194,6 +197,9 @@ PORT=3001
CORS_ORIGINS=http://localhost:5173
ADMIN_API_KEY=<generate-a-strong-random-key>
DATA_FILE=data.json
# Optional CDN for relative image/document metadata URLs
# CDN_URL=https://cdn.example.com
# ASSET_CDN_URL=https://assets-cdn.example.com
```

### 6. Run the Application
Expand Down
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:4173
# How long (seconds) cached API responses are kept. Must be a positive integer.
# CACHE_TTL_SECONDS=60

# CDN base URL used when API metadata stores relative image/document paths.
# ASSET_CDN_URL overrides CDN_URL for uploaded asset metadata.
# Example: https://assets-cdn.example.com
# CDN_URL=
# ASSET_CDN_URL=

# ── Optional Sentry (error tracking disabled when unset) ──────────────────────

# SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
Expand Down
26 changes: 26 additions & 0 deletions backend/__tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,32 @@ describe('GET /api/rwa', () => {
expect(res.body.pagination.totalPages).toBeGreaterThanOrEqual(1);
});

test('returns relative image and document paths through configured CDN', async () => {
const previousAssetCdnUrl = process.env.ASSET_CDN_URL;
process.env.ASSET_CDN_URL = 'https://assets.example.com';
const contractId = 'C' + 'D'.repeat(55);

try {
await createAndApproveAsset({
contractId,
title: 'CDN Enabled Asset',
location: 'Singapore',
description: 'Asset with relative media paths',
assetType: 'Real Estate',
imageUrl: '/uploads/cdn-asset.jpg',
documents: ['/documents/deed.pdf'],
});

const res = await request(app).get(`/api/rwa/${contractId}`);
expect(res.status).toBe(200);
expect(res.body.imageUrl).toBe('https://assets.example.com/uploads/cdn-asset.jpg');
expect(res.body.documents).toEqual(['https://assets.example.com/documents/deed.pdf']);
} finally {
if (previousAssetCdnUrl === undefined) delete process.env.ASSET_CDN_URL;
else process.env.ASSET_CDN_URL = previousAssetCdnUrl;
}
});

test('filters by assetType', async () => {
const res = await request(app).get('/api/rwa?assetType=agriculture');
expect(res.status).toBe(200);
Expand Down
58 changes: 58 additions & 0 deletions backend/__tests__/cdn.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { resolveCdnAssetUrl, withCdnAssetUrls } from '../cdn.js';

describe('CDN asset URL helpers', () => {
const originalCdnUrl = process.env.CDN_URL;
const originalAssetCdnUrl = process.env.ASSET_CDN_URL;

afterEach(() => {
if (originalCdnUrl === undefined) delete process.env.CDN_URL;
else process.env.CDN_URL = originalCdnUrl;

if (originalAssetCdnUrl === undefined) delete process.env.ASSET_CDN_URL;
else process.env.ASSET_CDN_URL = originalAssetCdnUrl;
});

test('prefixes relative uploaded asset paths with the CDN base URL', () => {
expect(resolveCdnAssetUrl('/uploads/asset.jpg', 'https://cdn.example.com')).toBe(
'https://cdn.example.com/uploads/asset.jpg'
);
expect(resolveCdnAssetUrl('documents/deed.pdf', 'https://cdn.example.com/assets')).toBe(
'https://cdn.example.com/assets/documents/deed.pdf'
);
});

test('preserves absolute and protocol URLs', () => {
expect(resolveCdnAssetUrl('https://example.com/asset.jpg', 'https://cdn.example.com')).toBe(
'https://example.com/asset.jpg'
);
expect(resolveCdnAssetUrl('ipfs://asset-hash', 'https://cdn.example.com')).toBe('ipfs://asset-hash');
expect(resolveCdnAssetUrl('data:image/png;base64,abc', 'https://cdn.example.com')).toBe(
'data:image/png;base64,abc'
);
});

test('rewrites image and document URLs without mutating stored metadata objects', () => {
process.env.ASSET_CDN_URL = 'https://assets.example.com';
const asset = {
imageUrl: '/uploads/home.jpg',
documents: [
'/uploads/title.pdf',
{ url: 'docs/report.pdf', label: 'Report' },
{ url: 'https://example.com/external.pdf', label: 'External' },
],
};

const result = withCdnAssetUrls(asset);

expect(result).toEqual({
imageUrl: 'https://assets.example.com/uploads/home.jpg',
documents: [
'https://assets.example.com/uploads/title.pdf',
{ url: 'https://assets.example.com/docs/report.pdf', label: 'Report' },
{ url: 'https://example.com/external.pdf', label: 'External' },
],
});
expect(asset.imageUrl).toBe('/uploads/home.jpg');
expect(asset.documents[1].url).toBe('docs/report.pdf');
});
});
67 changes: 67 additions & 0 deletions backend/cdn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Tokenized Fractional RWA Marketplace Contributors
// SPDX-License-Identifier: MIT

function normalizeBaseUrl(value) {
if (!value) return '';

const trimmed = value.trim();
if (!trimmed) return '';

try {
const url = new URL(trimmed);
url.pathname = url.pathname.replace(/\/+$/, '');
return url.toString().replace(/\/+$/, '');
} catch {
return '';
}
}

function isExternalUrl(value) {
return /^[a-z][a-z\d+\-.]*:/i.test(value) || value.startsWith('//');
}

export function getAssetCdnUrl() {
return normalizeBaseUrl(process.env.ASSET_CDN_URL || process.env.CDN_URL);
}

export function resolveCdnAssetUrl(value, cdnBaseUrl = getAssetCdnUrl()) {
if (typeof value !== 'string') return value;

const trimmed = value.trim();
if (!trimmed || !cdnBaseUrl || isExternalUrl(trimmed)) return value;

const path = trimmed.replace(/^\/+/, '');
return new URL(path, `${cdnBaseUrl}/`).toString();
}

export function withCdnAssetUrls(asset) {
if (!asset || typeof asset !== 'object') return asset;

const cdnBaseUrl = getAssetCdnUrl();
if (!cdnBaseUrl) return asset;

const next = { ...asset };
next.imageUrl = resolveCdnAssetUrl(next.imageUrl, cdnBaseUrl);

if (Array.isArray(next.documents)) {
next.documents = next.documents.map((document) => {
if (typeof document === 'string') {
return resolveCdnAssetUrl(document, cdnBaseUrl);
}

if (!document || typeof document !== 'object') {
return document;
}

const resolved = { ...document };
['url', 'href', 'fileUrl'].forEach((field) => {
if (field in resolved) {
resolved[field] = resolveCdnAssetUrl(resolved[field], cdnBaseUrl);
}
});
return resolved;
});
}

return next;
}
9 changes: 9 additions & 0 deletions backend/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ const RULES = [
},
];

function isValidHttpUrl(value) {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}

export function validateEnv() {
// Skip strict validation in test environment
if (process.env.NODE_ENV === 'test') return;
Expand Down
18 changes: 9 additions & 9 deletions backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ v1.get('/rwa/export', adminAuth, (req, res) => {
if (to && isNaN(toDate)) return res.status(400).json({ error: 'Invalid "to" date' });

const data = loadData();
let assets = Object.entries(data).map(([contractId, meta]) => ({ contractId, ...meta }));
let assets = Object.entries(data).map(([contractId, meta]) => withCdnAssetUrls({ contractId, ...meta }));

if (fromDate) assets = assets.filter(a => new Date(a.updatedAt || a.createdAt) >= fromDate);
if (toDate) assets = assets.filter(a => new Date(a.updatedAt || a.createdAt) <= toDate);
Expand Down Expand Up @@ -597,7 +597,7 @@ v1.get('/rwa', (req, res) => {
const data = loadData();
let assets = Object.entries(data)
.filter(([, meta]) => isApproved(meta))
.map(([contractId, meta]) => ({ contractId, ...meta }));
.map(([contractId, meta]) => withCdnAssetUrls({ contractId, ...meta }));

// Filter: assetType (case-insensitive) — faceted filter
const { assetType, location, search, page, limit } = req.query;
Expand Down Expand Up @@ -771,7 +771,7 @@ v1.get('/rwa/pending', adminAuth, (req, res) => {
const data = loadData();
const pending = Object.entries(data)
.filter(([, meta]) => meta.status === ASSET_STATUS.PENDING)
.map(([contractId, meta]) => ({ contractId, ...meta }));
.map(([contractId, meta]) => withCdnAssetUrls({ contractId, ...meta }));
res.json(pending);
});

Expand Down Expand Up @@ -806,7 +806,7 @@ v1.get('/rwa/:contractId', async (req, res) => {
const { contractId } = req.params;

const cached = await cacheGet(cacheKey(contractId));
if (cached) return res.json(cached);
if (cached) return res.json(withCdnAssetUrls(cached));

const data = loadData();
const asset = data[contractId];
Expand All @@ -816,7 +816,7 @@ v1.get('/rwa/:contractId', async (req, res) => {
const result = { contractId, ...asset };
// Cache individual asset (fire-and-forget)
cacheSet(cacheKey(contractId), result).catch(() => {});
res.json(result);
res.json(withCdnAssetUrls(result));
});

/**
Expand Down Expand Up @@ -888,7 +888,7 @@ v1.post('/rwa', adminAuth, writeLimiter, async (req, res) => {
fireWebhooks(WEBHOOK_EVENTS.CREATED, { contractId, ...data[contractId] }).catch(() => {});

req.log?.info({ contractId }, 'Asset created/updated');
res.status(201).json({ contractId, ...data[contractId] });
res.status(201).json(withCdnAssetUrls({ contractId, ...data[contractId] }));
});

/**
Expand Down Expand Up @@ -1061,7 +1061,7 @@ v1.patch('/rwa/:contractId', adminAuth, writeLimiter, async (req, res) => {
fireWebhooks(WEBHOOK_EVENTS.UPDATED, { contractId, ...data[contractId] }).catch(() => {});

req.log?.info({ contractId, fields: Object.keys(patch) }, 'Asset partially updated');
res.json({ contractId, ...data[contractId] });
res.json(withCdnAssetUrls({ contractId, ...data[contractId] }));
});

/**
Expand Down Expand Up @@ -1206,7 +1206,7 @@ v1.post('/rwa/:contractId/approve', adminAuth, writeLimiter, async (req, res) =>
cacheDel('rwa:all', cacheKey(contractId)).catch(() => {});
fireWebhooks(WEBHOOK_EVENTS.APPROVED, { contractId, ...data[contractId] }).catch(() => {});
req.log?.info({ contractId }, 'Asset approved');
res.json({ contractId, ...data[contractId] });
res.json(withCdnAssetUrls({ contractId, ...data[contractId] }));
});

/**
Expand Down Expand Up @@ -1247,7 +1247,7 @@ v1.post('/rwa/:contractId/reject', adminAuth, writeLimiter, async (req, res) =>
cacheDel('rwa:all', cacheKey(contractId)).catch(() => {});
fireWebhooks(WEBHOOK_EVENTS.REJECTED, { contractId, ...data[contractId] }).catch(() => {});
req.log?.info({ contractId }, 'Asset rejected');
res.json({ contractId, ...data[contractId] });
res.json(withCdnAssetUrls({ contractId, ...data[contractId] }));
});

// ── News / Updates ────────────────────────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ services:
VITE_RPC_URL: ${VITE_RPC_URL:-https://soroban-testnet.stellar.org:443}
VITE_NETWORK_PASSPHRASE: ${VITE_NETWORK_PASSPHRASE:-Test SDF Network ; September 2015}
VITE_API_URL: ${VITE_API_URL:-http://localhost:3001}
VITE_CDN_URL: ${VITE_CDN_URL:-}
ports:
- "5173:5173"
volumes:
Expand All @@ -60,6 +61,7 @@ services:
VITE_RPC_URL: ${VITE_RPC_URL:-https://soroban-testnet.stellar.org:443}
VITE_NETWORK_PASSPHRASE: ${VITE_NETWORK_PASSPHRASE:-Test SDF Network ; September 2015}
VITE_API_URL: ${VITE_API_URL:-http://backend:3001}
VITE_CDN_URL: ${VITE_CDN_URL:-}
restart: unless-stopped
ports:
- "80:80"
Expand Down
85 changes: 85 additions & 0 deletions docs/cdn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# CDN Configuration

This project supports CDN delivery for both production frontend build assets and asset metadata URLs for uploaded images/documents.

## Provider

Use Cloudflare in front of the deployed frontend/static origin and uploaded asset origin. AWS CloudFront can also work if you map the same public CDN hostnames to the same origins, but the bundled invalidation script currently supports Cloudflare.

Recommended hostnames:

| Purpose | Example |
| --- | --- |
| Frontend build assets | `https://cdn.example.com` |
| Uploaded images/documents | `https://assets-cdn.example.com` |

## Frontend Build Assets

Set `VITE_CDN_URL` when building the frontend:

```env
VITE_CDN_URL=https://cdn.example.com
```

Vite uses this value as its production `base`, so generated JavaScript, CSS, and image references point at the CDN. Leave it unset for local development.

For Docker production builds:

```bash
VITE_CDN_URL=https://cdn.example.com docker compose --profile prod up --build
```

## Uploaded Images And Documents

Set the backend CDN base URL:

```env
CDN_URL=https://cdn.example.com
ASSET_CDN_URL=https://assets-cdn.example.com
```

`ASSET_CDN_URL` is optional and overrides `CDN_URL` for asset metadata. When the API stores relative paths like `/uploads/home.jpg` or `documents/deed.pdf`, responses rewrite those paths to the CDN URL. Absolute URLs such as `https://...`, `ipfs://...`, and `data:` are preserved.

## Cloudflare Cache Rules

Use separate cache behavior for immutable build assets and frequently changing API/static documents:

| Path | Cache-Control |
| --- | --- |
| `/assets/*` | `public, max-age=31536000, immutable` |
| `/uploads/*`, `/documents/*` | `public, max-age=86400, stale-while-revalidate=604800` |
| `/index.html` | `public, max-age=0, must-revalidate` |

When the CDN fronts the API host, do not cache `/api/*`.

## Cache Invalidation

The script supports Cloudflare full-zone purges and URL-specific purges.

```bash
CDN_PROVIDER=cloudflare \
CLOUDFLARE_ZONE_ID=<zone-id> \
CLOUDFLARE_API_TOKEN=<api-token> \
node scripts/invalidate-cdn-cache.mjs
```

To purge specific URLs:

```bash
CDN_PROVIDER=cloudflare \
CLOUDFLARE_ZONE_ID=<zone-id> \
CLOUDFLARE_API_TOKEN=<api-token> \
CDN_INVALIDATION_URLS=https://cdn.example.com/assets/index.js,https://assets-cdn.example.com/uploads/home.jpg \
node scripts/invalidate-cdn-cache.mjs
```

## Performance Checks

Before enabling the CDN, capture baseline values with browser dev tools or Lighthouse:

- Homepage first load
- Largest image load time
- Total transferred bytes
- Backend bandwidth from static assets

After enabling the CDN, repeat the same tests and verify responses include Cloudflare headers such as `cf-cache-status: HIT` after the first request.
Loading