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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,27 @@ Sysnode is designed to make that information easier to read, easier to verify, a

## Data Source

The frontend reads live dashboard data from the Sysnode backend API. Anonymous public data can still be retargeted with `REACT_APP_API_BASE`, but the authenticated custody surface (`/auth`, `/vault`, `/gov`) is designed for same-origin production deployment:
The frontend reads live dashboard data from the Sysnode backend API. Production is designed for same-origin deployment:

```text
https://sysnode.info/ -> SPA
https://sysnode.info/auth/* -> backend
https://sysnode.info/vault/* -> backend
https://sysnode.info/gov/* -> backend
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route anonymous API paths through production proxy map

The same-origin deployment map now lists only /auth/*, /vault/*, and /gov/*, but this commit also changed production anonymous calls to same-origin '/mnStats', '/mnCount', and '/govlist' (src/lib/api.js). If operators follow this README block literally, those anonymous endpoints are not proxied to the backend, so public dashboard data requests will hit the SPA server (GETs return index.html, POST /govlist fails), breaking core data views in production.

Useful? React with 👍 / 👎.

https://sysnode.info/mnStats -> backend
https://sysnode.info/mnCount -> backend
https://sysnode.info/govlist -> backend
```

The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses, market APIs, and supporting network datasets. For a fork or private deployment, override the API base URL at build time (no code change required):
The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses, market APIs, and supporting network datasets. For a fork or private production deployment, keep the same-origin shape and point the reverse proxy at that deployment's backend. This keeps the CSP `connect-src` policy, host-only cookies, and CSRF model in lockstep.

For local or non-production testing, you can override the API base URL while running the dev server:

```bash
REACT_APP_API_BASE=https://your-backend.example npm run build
REACT_APP_API_BASE=https://your-backend.example npm start
```

The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats). Without it, development authenticated requests use `http://localhost:3001`; production authenticated requests use same-origin relative paths. Keeping the authenticated API under the SPA origin preserves host-only `Secure; SameSite=Lax` cookies and lets the SPA mirror the CSRF cookie into `X-CSRF-Token` without cross-site credentialed fetches.
The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats) outside production. Without it, development requests use `http://localhost:3001`; production requests use same-origin relative paths. Keeping the API under the SPA origin preserves host-only `Secure; SameSite=Lax` cookies and lets the SPA mirror the CSRF cookie into `X-CSRF-Token` without cross-site credentialed fetches.

## Getting Started

Expand Down
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^5.3.4",
"react-scripts": "5.0.1"
"react-scripts": "5.0.1",
"zxcvbn": "^4.4.2"
},
"scripts": {
"start": "react-scripts start",
Expand Down
12 changes: 10 additions & 2 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,18 @@ function uniqueSources(sources) {
return [...new Set(sources.filter(Boolean))];
}

function explicitConnectSources(value) {
return splitCspSources(value).filter(
(source) => !['*', 'http:', 'https:', 'ws:', 'wss:'].includes(source)
);
}

const connectSrc = uniqueSources([
"'self'",
'https:',
...splitCspSources(process.env.SYSNODE_CSP_CONNECT_SRC),
// Key-custody pages must not allow arbitrary HTTPS exfiltration. Production
// API traffic is same-origin by default; deployments that truly need another
// endpoint can add exact origins via SYSNODE_CSP_CONNECT_SRC.
...explicitConnectSources(process.env.SYSNODE_CSP_CONNECT_SRC),
Comment on lines 26 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow default API origin in connect-src

connect-src now resolves to 'self' plus optional SYSNODE_CSP_CONNECT_SRC, but the app’s anonymous client still defaults production traffic to https://syscoin.dev (see src/lib/api.js), so default builds with no extra CSP env will have browser-blocked fetch calls for network/governance data. This regresses the out-of-the-box production path unless deployers add a second, synchronized env var; the policy should include the default API origin (or derive allowed origins from the API base setting) to avoid breaking those pages.

Useful? React with 👍 / 👎.

]);

// HSTS is owned in code so any deployer (behind nginx, Caddy, a managed load
Expand Down
50 changes: 50 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -1844,6 +1844,56 @@
color: var(--muted);
}

.password-meter {
display: grid;
gap: 6px;
margin-top: 2px;
font-size: 0.82rem;
color: var(--muted);
}

.password-meter__topline {
display: flex;
justify-content: space-between;
gap: 12px;
}

.password-meter__topline strong {
color: var(--text);
}

.password-meter__track {
height: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(20, 30, 43, 0.1);
}

.password-meter__bar {
display: block;
height: 100%;
border-radius: inherit;
background: #c63024;
transition: width 140ms ease, background-color 140ms ease;
}

.password-meter[data-score='1'] .password-meter__bar {
background: #d97826;
}

.password-meter[data-score='2'] .password-meter__bar {
background: #caa21d;
}

.password-meter[data-score='3'] .password-meter__bar,
.password-meter[data-score='4'] .password-meter__bar {
background: #238857;
}

.password-meter__feedback {
margin: 0;
}

.auth-alert {
background: rgba(229, 107, 85, 0.1);
border: 1px solid rgba(229, 107, 85, 0.35);
Expand Down
10 changes: 9 additions & 1 deletion src/components/ChangePasswordCard.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useState } from 'react';

import { authService as defaultAuthService } from '../lib/authService';
import PasswordStrengthMeter from './PasswordStrengthMeter';
import { useAuth } from '../context/AuthContext';
import { useVault } from '../context/VaultContext';
import {
Expand Down Expand Up @@ -109,6 +110,7 @@ export default function ChangePasswordCard({
const [localError, setLocalError] = useState(null);
const [success, setSuccess] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const userEmail = user && user.email ? user.email : '';

const clearFeedback = useCallback(() => {
setErrCode(null);
Expand All @@ -130,7 +132,7 @@ export default function ChangePasswordCard({
setLocalError('Enter your current password.');
return;
}
const passwordError = validateVaultPassword(newPassword);
const passwordError = validateVaultPassword(newPassword, [user.email]);
if (passwordError) {
setLocalError(passwordError.message);
return;
Expand Down Expand Up @@ -329,9 +331,15 @@ export default function ChangePasswordCard({
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={MIN_VAULT_PASSWORD_LENGTH}
aria-describedby="cp-new-strength"
required
/>
<span className="auth-hint">{VAULT_PASSWORD_HINT}</span>
<PasswordStrengthMeter
id="cp-new-strength"
password={newPassword}
userInputs={[userEmail]}
/>
</div>

<div className="auth-field">
Expand Down
7 changes: 3 additions & 4 deletions src/components/ChangePasswordCard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,18 +193,17 @@ describe('ChangePasswordCard', () => {
target: { value: 'current-password-xyz' },
});
fireEvent.change(screen.getByLabelText(/^new password$/i), {
target: { value: 'short-password' },
target: { value: 'Password1!' },
});
fireEvent.change(screen.getByLabelText(/confirm new password/i), {
target: { value: 'short-password' },
target: { value: 'Password1!' },
});
await act(async () => {
fireEvent.submit(screen.getByTestId('change-password-card'));
});

const error = screen.getByTestId('change-password-local-error');
expect(error).toHaveTextContent(/at least 8/i);
expect(error).toHaveTextContent(/3 of/i);
expect(error).toHaveTextContent(/common|another word/i);
expect(cardAuthService.deriveChangePasswordKeys).not.toHaveBeenCalled();
expect(cardAuthService.changePassword).not.toHaveBeenCalled();
});
Expand Down
62 changes: 62 additions & 0 deletions src/components/PasswordStrengthMeter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { useMemo } from 'react';

import {
estimateVaultPasswordStrength,
MIN_VAULT_PASSWORD_LENGTH,
MIN_VAULT_PASSWORD_SCORE,
passwordStrengthFeedback,
passwordStrengthLabel,
VAULT_PASSWORD_HINT,
} from '../lib/passwordPolicy';

export default function PasswordStrengthMeter({
password,
userInputs,
id,
describedBy,
}) {
const result = useMemo(
() => estimateVaultPasswordStrength(password, userInputs),
[password, userInputs]
);
const hasPassword = typeof password === 'string' && password.length > 0;
const lengthOk = hasPassword && password.length >= MIN_VAULT_PASSWORD_LENGTH;
const scoreOk = lengthOk && result.score >= MIN_VAULT_PASSWORD_SCORE;
const label = hasPassword ? passwordStrengthLabel(result.score) : 'Start typing';
const feedback = !hasPassword
? VAULT_PASSWORD_HINT
: !lengthOk
? `Use at least ${MIN_VAULT_PASSWORD_LENGTH} characters.`
: scoreOk
? 'Looks strong enough for vault encryption.'
: passwordStrengthFeedback(result);

return (
<div
className="password-meter"
data-score={hasPassword ? result.score : -1}
data-valid={scoreOk ? 'true' : 'false'}
id={id}
aria-describedby={describedBy}
>
<div className="password-meter__topline">
<span>Password strength</span>
<strong>{label}</strong>
</div>
<div
className="password-meter__track"
role="meter"
aria-valuemin={0}
aria-valuemax={4}
aria-valuenow={hasPassword ? result.score : 0}
aria-valuetext={label}
>
<span
className="password-meter__bar"
style={{ width: `${hasPassword ? ((result.score + 1) / 5) * 100 : 0}%` }}
/>
</div>
<p className="password-meter__feedback">{feedback}</p>
</div>
);
}
11 changes: 5 additions & 6 deletions src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@ import axios from 'axios';
// flips true. Codex PR20 round 7 P1.
//
// Priority (mirrors apiClient.js):
// 1. REACT_APP_API_BASE (build-time override for bespoke deployments)
// 2. Production builds → https://syscoin.dev (the default
// hosted backend, same host the authenticated client picks up)
// 1. Production builds → same-origin relative paths. The deployment
// reverse-proxies these anonymous endpoints next to the SPA.
// 2. Non-production REACT_APP_API_BASE override for local/bespoke testing.
// 3. Development builds → http://localhost:3001 (backend dev server)
const DEFAULT_BASE =
process.env.REACT_APP_API_BASE ||
(process.env.NODE_ENV === 'production'
? 'https://syscoin.dev'
: 'http://localhost:3001');
? ''
: process.env.REACT_APP_API_BASE || 'http://localhost:3001');

const client = axios.create({
baseURL: DEFAULT_BASE,
Expand Down
8 changes: 4 additions & 4 deletions src/lib/apiClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,18 @@ import axios from 'axios';
// Default API base URL.
//
// Priority:
// 1. REACT_APP_API_BASE (build-time override for bespoke deployments)
// 2. Production builds → same-origin relative paths. Production must
// 1. Production builds → same-origin relative paths. Production must
// reverse-proxy /auth, /vault, and /gov under the SPA origin so
// host-only SameSite=Lax cookies and the readable csrf cookie work
// without cross-site credentialed fetches.
// 2. Non-production REACT_APP_API_BASE override for local/bespoke testing.
// 3. Development builds → http://localhost:3001 (backend dev server)
export function resolveDefaultApiBase({
apiBase = process.env.REACT_APP_API_BASE,
nodeEnv = process.env.NODE_ENV,
} = {}) {
if (apiBase) return apiBase;
return nodeEnv === 'production' ? '' : 'http://localhost:3001';
if (nodeEnv === 'production') return '';
return apiBase || 'http://localhost:3001';
}

const DEFAULT_BASE = resolveDefaultApiBase();
Expand Down
13 changes: 11 additions & 2 deletions src/lib/apiClient.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ describe('readCsrfCookie', () => {
});

describe('resolveDefaultApiBase', () => {
test('uses explicit REACT_APP_API_BASE override', () => {
test('keeps production authenticated calls same-origin even with an override', () => {
expect(
resolveDefaultApiBase({
apiBase: 'https://api.example.test',
nodeEnv: 'production',
})
).toBe('https://api.example.test');
).toBe('');
});

test('defaults production authenticated calls to same-origin relative paths', () => {
Expand All @@ -60,6 +60,15 @@ describe('resolveDefaultApiBase', () => {
'http://localhost:3001'
);
});

test('uses explicit REACT_APP_API_BASE override outside production', () => {
expect(
resolveDefaultApiBase({
apiBase: 'https://api.example.test',
nodeEnv: 'development',
})
).toBe('https://api.example.test');
});
});

describe('createApiClient — CSRF attachment', () => {
Expand Down
Loading
Loading