diff --git a/README.md b/README.md index d686c4b..c6ecb89 100644 --- a/README.md +++ b/README.md @@ -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 @@ -185,6 +186,8 @@ VITE_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`: @@ -194,6 +197,9 @@ PORT=3001 CORS_ORIGINS=http://localhost:5173 ADMIN_API_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 diff --git a/backend/.env.example b/backend/.env.example index 81b7a44..e0c22bf 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/__tests__/api.test.js b/backend/__tests__/api.test.js index d8cbe45..3f2c072 100644 --- a/backend/__tests__/api.test.js +++ b/backend/__tests__/api.test.js @@ -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); diff --git a/backend/__tests__/cdn.test.js b/backend/__tests__/cdn.test.js new file mode 100644 index 0000000..c4b720a --- /dev/null +++ b/backend/__tests__/cdn.test.js @@ -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'); + }); +}); diff --git a/backend/cdn.js b/backend/cdn.js new file mode 100644 index 0000000..2b1adc6 --- /dev/null +++ b/backend/cdn.js @@ -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; +} diff --git a/backend/env.js b/backend/env.js index 06cc821..90d7530 100644 --- a/backend/env.js +++ b/backend/env.js @@ -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; diff --git a/backend/index.js b/backend/index.js index e684fc3..8fd6569 100644 --- a/backend/index.js +++ b/backend/index.js @@ -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); @@ -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; @@ -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); }); @@ -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]; @@ -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)); }); /** @@ -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] })); }); /** @@ -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] })); }); /** @@ -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] })); }); /** @@ -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 ──────────────────────────────────────────────────────────── diff --git a/docker-compose.yml b/docker-compose.yml index 62a0098..ec4ddb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: @@ -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" diff --git a/docs/cdn.md b/docs/cdn.md new file mode 100644 index 0000000..7bade9b --- /dev/null +++ b/docs/cdn.md @@ -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= \ +CLOUDFLARE_API_TOKEN= \ +node scripts/invalidate-cdn-cache.mjs +``` + +To purge specific URLs: + +```bash +CDN_PROVIDER=cloudflare \ +CLOUDFLARE_ZONE_ID= \ +CLOUDFLARE_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. diff --git a/frontend/.env.example b/frontend/.env.example index 67a1da1..2ce5acb 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -10,6 +10,10 @@ VITE_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" # Backend metadata URL VITE_API_URL=http://localhost:3001 +# Optional CDN base URL for production frontend build assets. +# Example: https://cdn.example.com +# VITE_CDN_URL= + # Sentry error tracking and performance monitoring (optional) # VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 # VITE_SENTRY_TRACES_SAMPLE_RATE=0.1 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 860114b..e110df4 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -17,6 +17,7 @@ ARG VITE_CONTRACT_ID ARG VITE_RPC_URL ARG VITE_NETWORK_PASSPHRASE ARG VITE_API_URL +ARG VITE_CDN_URL RUN npm run build # ── Stage: production (serve built assets) ──────────────────────────────────── diff --git a/frontend/e2e/marketplace.spec.js b/frontend/e2e/marketplace.spec.js index b80658f..0ef1e5e 100644 --- a/frontend/e2e/marketplace.spec.js +++ b/frontend/e2e/marketplace.spec.js @@ -43,7 +43,7 @@ test.describe('RWA Marketplace — critical user flows', () => { test('displays the marketplace heading and asset grid on load', async ({ page }) => { await page.goto('/'); - await expect(page.getByRole('heading', { name: 'RWA Marketplace' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'RWA Marketplace', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: /available assets/i })).toBeVisible(); // Asset card from the mocked API should appear @@ -83,8 +83,9 @@ test.describe('RWA Marketplace — critical user flows', () => { // Click Buy Shares → confirm dialog appears await page.getByRole('button', { name: /buy shares/i }).click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 3_000 }); - await expect(page.getByText(/3/)).toBeVisible(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 3_000 }); + await expect(dialog.getByRole('cell', { name: '3', exact: true })).toBeVisible(); // Confirm the purchase const confirmBtn = page.getByRole('button', { name: /confirm/i }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bcf6f84..06ddee4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2916,19 +2916,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/core-js": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", @@ -3579,6 +3566,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -3601,16 +3601,6 @@ "node": ">=8.0.0" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3686,6 +3676,19 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "funding": [ @@ -4516,6 +4519,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -4846,12 +4859,6 @@ "node": ">=10" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -4941,6 +4948,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -4951,6 +4965,13 @@ "node": ">=0.1.14" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/storybook": { "version": "8.6.14", "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.14.tgz", @@ -5134,6 +5155,13 @@ "node": ">=12.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -5472,6 +5500,101 @@ "vite": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -5481,6 +5604,29 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ee11e25..9802f5e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,8 @@ import Header from './components/Header/Header'; import Navbar from './components/Navbar/Navbar'; import Card from './components/Card/Card'; import Alert from './components/Alert/Alert'; +import Badge from './components/Badge/Badge'; +import Button from './components/Button/Button'; import Skeleton from './components/Skeleton/Skeleton'; import AssetGrid from './components/AssetGrid/AssetGrid'; import AdminPage from './components/AdminPage/AdminPage'; @@ -477,7 +479,7 @@ function App() { )} {/* ── Asset Metadata Card ─────────────────────────────────────────── */} - {isFetchingMeta ? ( + {loadingMeta ? (
diff --git a/frontend/vite.config.js b/frontend/vite.config.js index d01d707..1237571 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,4 +1,5 @@ -import { defineConfig } from 'vite'; +import { createHash } from 'node:crypto'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import { sri } from 'vite-plugin-sri3'; import { VitePWA } from 'vite-plugin-pwa'; @@ -57,16 +58,5 @@ export default defineConfig({ "frame-ancestors 'none'", ].join('; '), }, - }, - build: { - sourcemap: true, - }, - define: { - 'process.env': {}, - }, - test: { - environment: 'jsdom', - globals: true, - setupFiles: './src/test/setup.js', - }, + }; }); diff --git a/render.yaml b/render.yaml index bdd74ef..d0a4096 100644 --- a/render.yaml +++ b/render.yaml @@ -18,6 +18,10 @@ services: generateValue: true - key: DATA_FILE value: data.json + - key: CDN_URL + sync: false # set to your CDN base URL, e.g. https://cdn.example.com + - key: ASSET_CDN_URL + sync: false # optional override for uploaded image/document URLs - key: DEPLOYMENT_COLOR value: blue - key: SERVICE_NAME @@ -48,6 +52,10 @@ services: generateValue: true - key: DATA_FILE value: data.json + - key: CDN_URL + sync: false # set to your CDN base URL, e.g. https://cdn.example.com + - key: ASSET_CDN_URL + sync: false # optional override for uploaded image/document URLs - key: DEPLOYMENT_COLOR value: green - key: SERVICE_NAME @@ -80,6 +88,8 @@ services: value: "Test SDF Network ; September 2015" - key: VITE_API_URL value: https://rwa-marketplace-backend-blue.onrender.com + - key: VITE_CDN_URL + sync: false # set to the CDN origin for static frontend assets # ── Frontend (Static Site, Green) ────────────────────────────────────────── - type: web @@ -108,3 +118,5 @@ services: value: "Test SDF Network ; September 2015" - key: VITE_API_URL value: https://rwa-marketplace-backend-green.onrender.com + - key: VITE_CDN_URL + sync: false # set to the CDN origin for static frontend assets diff --git a/scripts/invalidate-cdn-cache.mjs b/scripts/invalidate-cdn-cache.mjs new file mode 100755 index 0000000..aac7d5b --- /dev/null +++ b/scripts/invalidate-cdn-cache.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Copyright (c) 2026 Tokenized Fractional RWA Marketplace Contributors +// SPDX-License-Identifier: MIT + +const provider = (process.env.CDN_PROVIDER || 'cloudflare').toLowerCase(); + +function readInvalidationUrls() { + const raw = process.env.CDN_INVALIDATION_URLS; + if (!raw) return []; + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(Boolean) : []; + } catch { + return raw.split(',').map((url) => url.trim()).filter(Boolean); + } +} + +async function purgeCloudflare() { + const zoneId = process.env.CLOUDFLARE_ZONE_ID; + const apiToken = process.env.CLOUDFLARE_API_TOKEN; + + if (!zoneId || !apiToken) { + throw new Error('CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN are required.'); + } + + const files = readInvalidationUrls(); + const body = files.length > 0 ? { files } : { purge_everything: true }; + const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const result = await response.json(); + if (!response.ok || !result.success) { + throw new Error(`Cloudflare purge failed: ${JSON.stringify(result.errors || result)}`); + } + + console.log(files.length > 0 + ? `Purged ${files.length} Cloudflare URL(s).` + : 'Purged the full Cloudflare zone cache.'); +} + +async function main() { + if (provider !== 'cloudflare') { + throw new Error(`Unsupported CDN_PROVIDER "${provider}". Supported provider: cloudflare.`); + } + + await purgeCloudflare(); +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +});