+ );
+ }
+
+ render(
+
+
+
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId('status')).toHaveTextContent('authenticated')
+ );
+
+ await act(async () => {
+ screen.getByText('logout').click();
+ await flush();
+ });
+
+ // Still authenticated — we refuse to claim local sign-out on failure.
+ expect(screen.getByTestId('status')).toHaveTextContent('authenticated');
+ expect(capturedError).not.toBeNull();
+ expect(capturedError.code).toBe('logout_failed');
+ expect(capturedError.status).toBe(503);
+});
+
+test('stale mount-time /auth/me 401 cannot overwrite a newer successful login (Codex round 1 P1)', async () => {
+ // Reproduce the slow-network race: /auth/me on mount lags behind the
+ // user's login click. The mount's 401 arrives LATER than the login
+ // success and must not demote us back to anonymous.
+ let rejectMe;
+ const mePending = new Promise((_resolve, reject) => {
+ rejectMe = reject;
+ });
+ const service = makeService({
+ // First call: the slow mount-time probe. Leave it pending — we'll
+ // reject it manually mid-test.
+ me: jest
+ .fn()
+ .mockReturnValueOnce(mePending)
+ // Second call: the login-driven me() lookup. Resolves immediately.
+ .mockResolvedValueOnce({
+ user: { id: 1, email: 'user@example.com', emailVerified: true },
+ }),
+ login: jest.fn().mockResolvedValue({
+ user: { id: 1, email: 'user@example.com' },
+ expiresAt: 1,
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ // We're still booting — the mount /auth/me has not resolved yet.
+ expect(screen.getByTestId('status')).toHaveTextContent('booting');
+
+ // User clicks login. It completes quickly (both authService.login and
+ // the login-scoped authService.me()).
+ await act(async () => {
+ screen.getByText('login').click();
+ await flush();
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId('status')).toHaveTextContent('authenticated')
+ );
+
+ // NOW the slow mount-time /auth/me finally rejects with 401. The old
+ // refresh() MUST NOT flip us back to anonymous — a newer operation
+ // (login) has already claimed the generation counter.
+ await act(async () => {
+ rejectMe(Object.assign(new Error('unauth'), { status: 401 }));
+ await flush();
+ });
+
+ // Give any spurious writes a chance to land before we assert.
+ await flush();
+ expect(screen.getByTestId('status')).toHaveTextContent('authenticated');
+ expect(screen.getByTestId('email')).toHaveTextContent('user@example.com');
+});
+
+test('failed login does not strand the UI in booting when mount refresh is still in flight (Codex round 3 P1)', async () => {
+ // Setup: mount-time /auth/me is pending. User clicks login, credentials
+ // are rejected. The mount refresh's eventual 401 MUST still be able to
+ // land ANONYMOUS — the failed login must not invalidate it by bumping
+ // the gen counter prematurely.
+ let rejectMountMe;
+ const mountMe = new Promise((_resolve, reject) => {
+ rejectMountMe = reject;
+ });
+ const service = makeService({
+ me: jest.fn().mockReturnValueOnce(mountMe),
+ login: jest.fn().mockRejectedValue(
+ Object.assign(new Error('bad creds'), {
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ ),
+ });
+
+ render(
+
+
+
+ );
+ expect(screen.getByTestId('status')).toHaveTextContent('booting');
+
+ // Fail the login while the mount refresh is still pending.
+ await act(async () => {
+ screen.getByText('login').click();
+ await flush();
+ });
+ // Login itself surfaces an error — status must still be booting, not
+ // authenticated.
+ expect(screen.getByTestId('status')).toHaveTextContent('booting');
+
+ // Now the mount /auth/me resolves 401. It MUST land ANONYMOUS — if the
+ // failed login had bumped the gen counter, this write would be dropped
+ // and the UI would be stuck in booting forever.
+ await act(async () => {
+ rejectMountMe(Object.assign(new Error('unauth'), { status: 401 }));
+ await flush();
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId('status')).toHaveTextContent('anonymous')
+ );
+});
+
+test('login rejects when follow-up /auth/me returns 401 (cookie did not stick) — Codex round 4 P1', async () => {
+ // authService.login succeeds — credentials were valid. But the browser
+ // never persisted the Set-Cookie (cross-origin + third-party-cookie
+ // block), so the very next /auth/me comes back 401. AuthContext MUST
+ // treat that as a real auth failure and stay anonymous rather than
+ // unlock protected routes from the shallow login response.
+ const service = makeService({
+ me: jest
+ .fn()
+ // Mount refresh: 401 (no session yet).
+ .mockRejectedValueOnce(
+ Object.assign(new Error('unauth'), { status: 401 })
+ )
+ // Post-login rehydrate: ALSO 401.
+ .mockRejectedValueOnce(
+ Object.assign(new Error('unauth'), { status: 401 })
+ ),
+ login: jest.fn().mockResolvedValue({
+ user: { id: 1, email: 'user@example.com' },
+ expiresAt: 1,
+ }),
+ });
+
+ let capturedError = null;
+
+ function Probe() {
+ const auth = useAuth();
+ return (
+
+ {auth.status}
+
+
+ );
+ }
+
+ render(
+
+
+
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId('status')).toHaveTextContent('anonymous')
+ );
+
+ await act(async () => {
+ screen.getByText('login').click();
+ await flush();
+ });
+
+ expect(screen.getByTestId('status')).toHaveTextContent('anonymous');
+ expect(capturedError).not.toBeNull();
+ expect(capturedError.code).toBe('session_not_established');
+ expect(capturedError.status).toBe(401);
+});
+
+test('login tolerates a 5xx/network blip on follow-up /auth/me (Codex round 4 P1)', async () => {
+ // Transient hiccup on the best-effort hydrate call must still land
+ // AUTHENTICATED using the shallow user the server already returned.
+ const service = makeService({
+ me: jest
+ .fn()
+ .mockRejectedValueOnce(
+ Object.assign(new Error('unauth'), { status: 401 })
+ )
+ .mockRejectedValueOnce(
+ Object.assign(new Error('oops'), { status: 503 })
+ ),
+ login: jest.fn().mockResolvedValue({
+ user: { id: 1, email: 'user@example.com' },
+ expiresAt: 1,
+ }),
+ });
+
+ render(
+
+
+
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId('status')).toHaveTextContent('anonymous')
+ );
+
+ await act(async () => {
+ screen.getByText('login').click();
+ await flush();
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId('status')).toHaveTextContent('authenticated')
+ );
+ expect(screen.getByTestId('email')).toHaveTextContent('user@example.com');
+});
+
+test('useAuth throws outside provider', () => {
+ function Bare() {
+ useAuth();
+ return null;
+ }
+ const previousError = console.error;
+ // eslint-disable-next-line no-console
+ console.error = jest.fn(); // suppress React's boundary noise in this negative test
+ expect(() => render()).toThrow(/must be used inside /);
+ // eslint-disable-next-line no-console
+ console.error = previousError;
+});
diff --git a/src/lib/apiClient.js b/src/lib/apiClient.js
new file mode 100644
index 0000000..d82acaa
--- /dev/null
+++ b/src/lib/apiClient.js
@@ -0,0 +1,173 @@
+import axios from 'axios';
+
+// Dedicated axios instance for the authenticated sysnode API surface
+// (`/auth/*`, `/vault/*`). Kept separate from the legacy anonymous client
+// in `./api.js` because:
+//
+// 1. This one MUST set `withCredentials: true` so the httpOnly session
+// cookie and the non-httpOnly csrf cookie round-trip correctly. The
+// legacy client talks to public endpoints that never see cookies.
+//
+// 2. It auto-attaches `X-CSRF-Token` on every state-changing request,
+// reading the value from the `csrf` cookie the backend set on login.
+// The backend's double-submit CSRF middleware rejects state-changing
+// requests that don't carry the header.
+//
+// 3. It routes 401 Unauthorized responses to a single `onAuthLost`
+// callback so the AuthContext can react (clear the cached user,
+// redirect to /login) without every caller having to handle the
+// case individually.
+
+// Default API base URL.
+//
+// DO NOT fall back to `window.location.origin`. The sysnode-info SPA
+// and the sysnode-backend are hosted on different origins — the SPA
+// server only serves static files, it does not proxy /auth/* anywhere
+// — so defaulting to the current browser origin makes login, register,
+// and session hydration silently hit the wrong host and fail unless
+// an operator remembers to set `REACT_APP_API_BASE`. (Codex round 5
+// P1.)
+//
+// Priority:
+// 1. REACT_APP_API_BASE (build-time override for bespoke deployments)
+// 2. Production builds → https://syscoin.dev (same host as the legacy
+// public client in `./api.js`, which is where sysnode-backend is
+// reachable)
+// 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');
+
+const STATE_CHANGING = /^(POST|PUT|PATCH|DELETE)$/i;
+
+function readCsrfCookie() {
+ if (typeof document === 'undefined' || !document.cookie) return null;
+ // Pairs like "csrf=abc; other=value" — split on "; " to handle modern
+ // browsers, fall back to ";" for older serializers.
+ const parts = document.cookie.split(/;\s*/);
+ for (const p of parts) {
+ const eq = p.indexOf('=');
+ if (eq === -1) continue;
+ if (p.slice(0, eq) === 'csrf') {
+ return decodeURIComponent(p.slice(eq + 1));
+ }
+ }
+ return null;
+}
+
+// Normalise axios errors into a predictable object the UI can render
+// without switching on HTTP status codes. We intentionally expose both the
+// raw status and the backend's `error` code so pages can choose whichever
+// they need.
+function toApiError(err) {
+ if (err && err.response) {
+ const { status, data } = err.response;
+ const code =
+ (data && typeof data === 'object' && (data.error || data.code)) ||
+ 'http_error';
+ const e = new Error(code);
+ e.code = code;
+ e.status = status;
+ e.details = data && data.details ? data.details : null;
+ e.response = err.response;
+ return e;
+ }
+ // Network / timeout / aborted before a response.
+ const e = new Error('network_error');
+ e.code = 'network_error';
+ e.status = 0;
+ e.cause = err;
+ return e;
+}
+
+export function createApiClient({
+ baseURL = DEFAULT_BASE,
+ readCsrf = readCsrfCookie,
+ onAuthLost,
+} = {}) {
+ // Do NOT set a global `Content-Type` here. Axios places headers from
+ // `create({ headers })` into its `common` bag, which is merged into
+ // EVERY request — including GETs with no body. Cross-origin deployments
+ // (REACT_APP_API_BASE pointing off-origin) treat any GET with a custom
+ // Content-Type as a non-simple CORS request and must first preflight
+ // it, so `/auth/me` on boot would fail outright if the API's OPTIONS
+ // response didn't whitelist Content-Type. Axios already sets the JSON
+ // content-type for POST/PUT/PATCH automatically when the body is an
+ // object, so dropping the global here loses nothing.
+ //
+ // (Codex round 3 P2.)
+ const instance = axios.create({
+ baseURL,
+ withCredentials: true,
+ timeout: 20000,
+ headers: {
+ Accept: 'application/json, text/plain, */*',
+ },
+ });
+
+ instance.interceptors.request.use(function attachCsrf(config) {
+ if (STATE_CHANGING.test(config.method || '')) {
+ const token = readCsrf();
+ if (token) {
+ config.headers = { ...(config.headers || {}), 'X-CSRF-Token': token };
+ }
+ }
+ return config;
+ });
+
+ instance.interceptors.response.use(
+ function passthrough(res) {
+ return res;
+ },
+ function normalise(error) {
+ const apiError = toApiError(error);
+ if (apiError.status === 401 && typeof onAuthLost === 'function') {
+ // Don't fire on the auth endpoints themselves — a 401 on /login is
+ // a credential error, not a session expiry.
+ const url = (error.config && error.config.url) || '';
+ if (!url.startsWith('/auth/')) {
+ try {
+ onAuthLost(apiError);
+ } catch (_) {
+ // Never let the callback break the error path.
+ }
+ }
+ }
+ return Promise.reject(apiError);
+ }
+ );
+
+ return instance;
+}
+
+// Mutable slot for the global auth-loss handler.
+//
+// The default singleton `apiClient` is created at module import time —
+// long before any React component (and therefore before AuthContext)
+// has mounted. If we baked `onAuthLost` into that `createApiClient()`
+// call directly, the closure would capture `undefined` and every 401
+// on a protected endpoint (e.g. `/vault`) would be silently dropped,
+// leaving the UI in a stale "authenticated" state until the user did
+// something that hit /auth/me. (Codex round 2 P2.)
+//
+// Instead, the singleton's onAuthLost is a thunk that reads this slot
+// at error-dispatch time. AuthProvider registers its handler via
+// `setAuthLostHandler` once it mounts; the slot is cleared on
+// unmount so stale providers can't fire.
+let globalAuthLost = null;
+
+export function setAuthLostHandler(fn) {
+ globalAuthLost = typeof fn === 'function' ? fn : null;
+}
+
+// Default module-level client, used by simple call sites. Tests and pages
+// that want to inject a mock should call `createApiClient` directly.
+export const apiClient = createApiClient({
+ onAuthLost: function dispatchAuthLost(err) {
+ if (typeof globalAuthLost === 'function') globalAuthLost(err);
+ },
+});
+
+export { readCsrfCookie, toApiError };
diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js
new file mode 100644
index 0000000..d358ec1
--- /dev/null
+++ b/src/lib/apiClient.test.js
@@ -0,0 +1,247 @@
+import MockAdapter from 'axios-mock-adapter';
+import {
+ apiClient,
+ createApiClient,
+ readCsrfCookie,
+ setAuthLostHandler,
+ toApiError,
+} from './apiClient';
+
+function setCookie(value) {
+ Object.defineProperty(document, 'cookie', {
+ configurable: true,
+ get() {
+ return value;
+ },
+ });
+}
+
+afterEach(() => {
+ setCookie('');
+});
+
+describe('readCsrfCookie', () => {
+ test('extracts csrf cookie value from document.cookie', () => {
+ setCookie('other=1; csrf=abcdef; trailing=x');
+ expect(readCsrfCookie()).toBe('abcdef');
+ });
+
+ test('returns null when cookie is absent', () => {
+ setCookie('other=1');
+ expect(readCsrfCookie()).toBeNull();
+ });
+
+ test('url-decodes cookie values', () => {
+ setCookie('csrf=abc%2Fdef');
+ expect(readCsrfCookie()).toBe('abc/def');
+ });
+});
+
+describe('createApiClient — CSRF attachment', () => {
+ test('attaches X-CSRF-Token to state-changing methods', async () => {
+ const client = createApiClient({
+ baseURL: 'http://test',
+ readCsrf: () => 'tok-123',
+ });
+ const adapter = new MockAdapter(client);
+ adapter.onPost('/auth/login').reply(200, { ok: true });
+
+ const res = await client.post('/auth/login', {});
+ expect(res.data).toEqual({ ok: true });
+ expect(adapter.history.post[0].headers['X-CSRF-Token']).toBe('tok-123');
+ });
+
+ test('does NOT attach the header on GET requests', async () => {
+ const client = createApiClient({
+ baseURL: 'http://test',
+ readCsrf: () => 'tok-123',
+ });
+ const adapter = new MockAdapter(client);
+ adapter.onGet('/auth/me').reply(200, {});
+
+ await client.get('/auth/me');
+ expect(adapter.history.get[0].headers['X-CSRF-Token']).toBeUndefined();
+ });
+
+ test('omits the header when no cookie is present', async () => {
+ const client = createApiClient({
+ baseURL: 'http://test',
+ readCsrf: () => null,
+ });
+ const adapter = new MockAdapter(client);
+ adapter.onPost('/vault').reply(200, {});
+ await client.post('/vault', {});
+ expect(adapter.history.post[0].headers['X-CSRF-Token']).toBeUndefined();
+ });
+});
+
+describe('createApiClient — no global Content-Type (Codex round 3 P2)', () => {
+ // A global Content-Type would convert cross-origin GETs into non-
+ // simple CORS preflights and break /auth/me on boot.
+ test('GET requests carry no Content-Type from client defaults', async () => {
+ const client = createApiClient({
+ baseURL: 'http://test',
+ readCsrf: () => null,
+ });
+ const adapter = new MockAdapter(client);
+ adapter.onGet('/auth/me').reply(200, {});
+
+ await client.get('/auth/me');
+ const headers = adapter.history.get[0].headers || {};
+ // Neither the per-request slot nor common should inject it.
+ expect(headers['Content-Type']).toBeUndefined();
+ // Axios also exposes common defaults on the instance — they must
+ // not contain Content-Type either.
+ expect(
+ client.defaults.headers &&
+ client.defaults.headers.common &&
+ client.defaults.headers.common['Content-Type']
+ ).toBeUndefined();
+ });
+
+ test('POST with a JSON body still gets Content-Type from axios body inference', async () => {
+ const client = createApiClient({
+ baseURL: 'http://test',
+ readCsrf: () => null,
+ });
+ const adapter = new MockAdapter(client);
+ adapter.onPost('/auth/login').reply(200, {});
+
+ await client.post('/auth/login', { email: 'a@b.com', password: 'p' });
+ const ct = adapter.history.post[0].headers['Content-Type'] || '';
+ expect(ct.toLowerCase()).toMatch(/^application\/json/);
+ });
+});
+
+describe('createApiClient — 401 handling', () => {
+ test('invokes onAuthLost for non-auth 401s', async () => {
+ const onAuthLost = jest.fn();
+ const client = createApiClient({
+ baseURL: 'http://test',
+ readCsrf: () => null,
+ onAuthLost,
+ });
+ const adapter = new MockAdapter(client);
+ adapter.onGet('/vault').reply(401, { error: 'unauthorized' });
+
+ await expect(client.get('/vault')).rejects.toMatchObject({
+ code: 'unauthorized',
+ status: 401,
+ });
+ expect(onAuthLost).toHaveBeenCalledTimes(1);
+ });
+
+ test('does NOT invoke onAuthLost for /auth/* 401s (credential errors)', async () => {
+ const onAuthLost = jest.fn();
+ const client = createApiClient({
+ baseURL: 'http://test',
+ readCsrf: () => null,
+ onAuthLost,
+ });
+ const adapter = new MockAdapter(client);
+ adapter.onPost('/auth/login').reply(401, { error: 'invalid_credentials' });
+
+ await expect(client.post('/auth/login', {})).rejects.toMatchObject({
+ code: 'invalid_credentials',
+ });
+ expect(onAuthLost).not.toHaveBeenCalled();
+ });
+
+ test('onAuthLost exception does not crash the error path', async () => {
+ const client = createApiClient({
+ baseURL: 'http://test',
+ readCsrf: () => null,
+ onAuthLost: () => {
+ throw new Error('boom');
+ },
+ });
+ const adapter = new MockAdapter(client);
+ adapter.onGet('/vault').reply(401, { error: 'unauthorized' });
+ await expect(client.get('/vault')).rejects.toMatchObject({
+ status: 401,
+ });
+ });
+});
+
+describe('default apiClient singleton — setAuthLostHandler wiring (Codex round 2 P2)', () => {
+ afterEach(() => {
+ setAuthLostHandler(null);
+ });
+
+ test('routes non-auth 401s to the registered handler', async () => {
+ const handler = jest.fn();
+ setAuthLostHandler(handler);
+
+ const adapter = new MockAdapter(apiClient);
+ adapter.onGet('/vault').reply(401, { error: 'unauthorized' });
+
+ await expect(apiClient.get('/vault')).rejects.toMatchObject({
+ status: 401,
+ });
+ expect(handler).toHaveBeenCalledTimes(1);
+
+ adapter.restore();
+ });
+
+ test('no-ops when no handler is registered (slot cleared)', async () => {
+ setAuthLostHandler(null);
+ const adapter = new MockAdapter(apiClient);
+ adapter.onGet('/vault').reply(401, { error: 'unauthorized' });
+
+ // Should reject cleanly without throwing from the interceptor.
+ await expect(apiClient.get('/vault')).rejects.toMatchObject({
+ status: 401,
+ });
+ adapter.restore();
+ });
+
+ test('ignores /auth/* 401s (credential errors, not session loss)', async () => {
+ const handler = jest.fn();
+ setAuthLostHandler(handler);
+ const adapter = new MockAdapter(apiClient);
+ adapter.onPost('/auth/login').reply(401, { error: 'invalid_credentials' });
+
+ await expect(apiClient.post('/auth/login', {})).rejects.toMatchObject({
+ code: 'invalid_credentials',
+ });
+ expect(handler).not.toHaveBeenCalled();
+ adapter.restore();
+ });
+
+ test('the latest registered handler wins (AuthProvider remount semantics)', async () => {
+ const first = jest.fn();
+ const second = jest.fn();
+ setAuthLostHandler(first);
+ setAuthLostHandler(second);
+
+ const adapter = new MockAdapter(apiClient);
+ adapter.onGet('/vault').reply(401);
+ await expect(apiClient.get('/vault')).rejects.toBeDefined();
+
+ expect(first).not.toHaveBeenCalled();
+ expect(second).toHaveBeenCalledTimes(1);
+ adapter.restore();
+ });
+});
+
+describe('toApiError', () => {
+ test('normalises response errors with backend error code', () => {
+ const e = toApiError({
+ response: { status: 409, data: { error: 'already_verified' } },
+ });
+ expect(e.code).toBe('already_verified');
+ expect(e.status).toBe(409);
+ });
+
+ test('falls back to http_error when no code present', () => {
+ const e = toApiError({ response: { status: 500, data: null } });
+ expect(e.code).toBe('http_error');
+ expect(e.status).toBe(500);
+ });
+
+ test('maps transport failures to network_error', () => {
+ const e = toApiError(new Error('ECONNREFUSED'));
+ expect(e.code).toBe('network_error');
+ expect(e.status).toBe(0);
+ });
+});
diff --git a/src/lib/authService.js b/src/lib/authService.js
new file mode 100644
index 0000000..70f900c
--- /dev/null
+++ b/src/lib/authService.js
@@ -0,0 +1,52 @@
+import { apiClient as defaultClient } from './apiClient';
+import { deriveLoginKeys } from './crypto/kdf';
+
+// High-level façade for the auth surface.
+//
+// The UI imports from here so it never has to know about:
+// - the fact that authHash is client-derived from password+email via
+// PBKDF2(600k)+HKDF, or
+// - the exact HTTP endpoints / error-code contract.
+//
+// The `client` parameter exists purely for dependency injection in tests.
+// Production code should use the default export, which is bound to the
+// shared apiClient (and therefore the shared 401 interceptor).
+
+export function createAuthService(client = defaultClient) {
+ async function register(email, password) {
+ const { authHash } = await deriveLoginKeys(password, email);
+ const res = await client.post('/auth/register', {
+ email: email.trim(),
+ authHash,
+ });
+ return res.data;
+ }
+
+ async function verifyEmail(token) {
+ const res = await client.post('/auth/verify-email', { token });
+ return res.data;
+ }
+
+ async function login(email, password) {
+ const { authHash } = await deriveLoginKeys(password, email);
+ const res = await client.post('/auth/login', {
+ email: email.trim(),
+ authHash,
+ });
+ return res.data;
+ }
+
+ async function logout() {
+ const res = await client.post('/auth/logout');
+ return res.data;
+ }
+
+ async function me() {
+ const res = await client.get('/auth/me');
+ return res.data;
+ }
+
+ return { register, verifyEmail, login, logout, me };
+}
+
+export const authService = createAuthService();
diff --git a/src/lib/authService.test.js b/src/lib/authService.test.js
new file mode 100644
index 0000000..152d8d9
--- /dev/null
+++ b/src/lib/authService.test.js
@@ -0,0 +1,95 @@
+import MockAdapter from 'axios-mock-adapter';
+import { createApiClient } from './apiClient';
+import { createAuthService } from './authService';
+
+function makeService() {
+ const client = createApiClient({
+ baseURL: 'http://test',
+ readCsrf: () => 'tok',
+ });
+ const adapter = new MockAdapter(client);
+ const service = createAuthService(client);
+ return { service, adapter };
+}
+
+describe('authService.register', () => {
+ test('derives authHash client-side and posts normalized email', async () => {
+ const { service, adapter } = makeService();
+ let captured;
+ adapter.onPost('/auth/register').reply((config) => {
+ captured = JSON.parse(config.data);
+ return [202, { status: 'verification_sent' }];
+ });
+ const res = await service.register(' User@Example.com ', 'hunter22a');
+ expect(res).toEqual({ status: 'verification_sent' });
+ expect(captured.email).toBe('User@Example.com');
+ expect(captured.authHash).toMatch(/^[0-9a-f]{64}$/);
+ }, 20000);
+});
+
+describe('authService.login', () => {
+ test('returns user payload on success', async () => {
+ const { service, adapter } = makeService();
+ adapter.onPost('/auth/login').reply(200, {
+ user: { id: 1, email: 'user@example.com' },
+ expiresAt: 123456,
+ });
+ const res = await service.login('user@example.com', 'hunter22a');
+ expect(res.user).toEqual({ id: 1, email: 'user@example.com' });
+ expect(res.expiresAt).toBe(123456);
+ }, 20000);
+
+ test('surfaces invalid_credentials code on 401', async () => {
+ const { service, adapter } = makeService();
+ adapter.onPost('/auth/login').reply(401, { error: 'invalid_credentials' });
+ await expect(service.login('x@y.com', 'bad')).rejects.toMatchObject({
+ code: 'invalid_credentials',
+ status: 401,
+ });
+ }, 20000);
+
+ test('surfaces email_not_verified code on 403', async () => {
+ const { service, adapter } = makeService();
+ adapter.onPost('/auth/login').reply(403, { error: 'email_not_verified' });
+ await expect(service.login('x@y.com', 'hunter22a')).rejects.toMatchObject({
+ code: 'email_not_verified',
+ });
+ }, 20000);
+});
+
+describe('authService.verifyEmail', () => {
+ test('returns status:verified', async () => {
+ const { service, adapter } = makeService();
+ adapter.onPost('/auth/verify-email').reply(200, { status: 'verified' });
+ const res = await service.verifyEmail('a'.repeat(64));
+ expect(res.status).toBe('verified');
+ });
+
+ test('maps invalid_or_expired_token error', async () => {
+ const { service, adapter } = makeService();
+ adapter
+ .onPost('/auth/verify-email')
+ .reply(400, { error: 'invalid_or_expired_token' });
+ await expect(service.verifyEmail('x')).rejects.toMatchObject({
+ code: 'invalid_or_expired_token',
+ });
+ });
+});
+
+describe('authService.me / logout', () => {
+ test('me returns user when authed', async () => {
+ const { service, adapter } = makeService();
+ adapter.onGet('/auth/me').reply(200, {
+ user: { id: 1, email: 'u@e.com', emailVerified: true, notificationPrefs: {} },
+ });
+ const res = await service.me();
+ expect(res.user.email).toBe('u@e.com');
+ });
+
+ test('logout returns ok', async () => {
+ const { service, adapter } = makeService();
+ adapter.onPost('/auth/logout').reply(200, { status: 'ok' });
+ const res = await service.logout();
+ expect(res.status).toBe('ok');
+ });
+});
diff --git a/src/lib/crypto/kdf.js b/src/lib/crypto/kdf.js
new file mode 100644
index 0000000..2a8e73d
--- /dev/null
+++ b/src/lib/crypto/kdf.js
@@ -0,0 +1,170 @@
+// Client-side key derivation.
+//
+// Contract (must match sysnode-backend/lib/kdf.js + routes/auth.js):
+//
+// master = PBKDF2-SHA512(password, NFKC(email), 600_000 iter, 32 bytes)
+// authHash = HKDF-SHA256(master, info="sysnode-auth-v1", 32 bytes)
+// vaultKey = HKDF-SHA256(master, info="sysnode-vault-v1", salt=saltV, 32 bytes)
+//
+// The backend never sees `master`, `password`, or `vaultKey`. It sees only
+// `authHash` (hex) during register/login and the opaque AES-GCM blob during
+// vault PUT/GET.
+//
+// Notes:
+// - PBKDF2 iteration count is intentionally fixed to match the backend
+// contract. We MUST NOT change it without a coordinated migration.
+// - HKDF-SHA256 with empty salt and a domain-separating `info` is a standard
+// sub-key derivation pattern (RFC 5869 §3.3).
+// - `vaultKey` uses HKDF with `salt=saltV` (per-user random from the server)
+// so that two users with the same password still end up with different
+// encryption keys for their vault blobs.
+
+import { normalizeEmail } from './normalize';
+
+const PBKDF2_ITERATIONS = 600000;
+const MASTER_BYTES = 32;
+const SUBKEY_BYTES = 32;
+
+const AUTH_INFO = 'sysnode-auth-v1';
+const VAULT_INFO = 'sysnode-vault-v1';
+
+function subtleCrypto() {
+ const c =
+ (typeof globalThis !== 'undefined' && globalThis.crypto) ||
+ (typeof window !== 'undefined' && window.crypto);
+ if (!c || !c.subtle) {
+ throw new Error(
+ 'WebCrypto is unavailable. A modern browser (Chrome/Edge/Firefox/Safari) is required.'
+ );
+ }
+ return c.subtle;
+}
+
+function encodeUtf8(text) {
+ return new TextEncoder().encode(text);
+}
+
+function toHex(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let out = '';
+ for (let i = 0; i < bytes.length; i += 1) {
+ out += bytes[i].toString(16).padStart(2, '0');
+ }
+ return out;
+}
+
+// Strict hex-to-bytes. `parseInt(x, 16)` tolerates partial parses — e.g.
+// `parseInt('Ax', 16) === 10` — which would silently coerce a malformed
+// saltV into a valid byte and produce a subtly wrong vault key. We guard
+// the ENTIRE input against `^[0-9a-fA-F]*$` up front so any non-hex
+// character anywhere throws, rather than leaking as an opaque AES-GCM
+// decrypt failure downstream. (Codex round 2 P2.)
+function fromHex(hex) {
+ if (typeof hex !== 'string' || hex.length % 2 !== 0) {
+ throw new Error('invalid hex');
+ }
+ if (!/^[0-9a-fA-F]*$/.test(hex)) {
+ throw new Error('invalid hex');
+ }
+ const out = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < out.length; i += 1) {
+ out[i] = parseInt(hex.substr(i * 2, 2), 16);
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Master key
+// ---------------------------------------------------------------------------
+
+export async function deriveMaster(password, email) {
+ if (typeof password !== 'string' || password.length === 0) {
+ throw new Error('password is required');
+ }
+ const salt = encodeUtf8(normalizeEmail(email));
+ if (salt.length === 0) throw new Error('email is required');
+
+ const subtle = subtleCrypto();
+ const baseKey = await subtle.importKey(
+ 'raw',
+ encodeUtf8(password),
+ { name: 'PBKDF2' },
+ false,
+ ['deriveBits']
+ );
+ const bits = await subtle.deriveBits(
+ {
+ name: 'PBKDF2',
+ hash: 'SHA-512',
+ salt,
+ iterations: PBKDF2_ITERATIONS,
+ },
+ baseKey,
+ MASTER_BYTES * 8
+ );
+ return new Uint8Array(bits);
+}
+
+// ---------------------------------------------------------------------------
+// HKDF subkeys
+// ---------------------------------------------------------------------------
+
+async function hkdfBytes(master, info, salt = new Uint8Array(0), length = SUBKEY_BYTES) {
+ const subtle = subtleCrypto();
+ const baseKey = await subtle.importKey(
+ 'raw',
+ master,
+ { name: 'HKDF' },
+ false,
+ ['deriveBits']
+ );
+ const bits = await subtle.deriveBits(
+ {
+ name: 'HKDF',
+ hash: 'SHA-256',
+ salt,
+ info: encodeUtf8(info),
+ },
+ baseKey,
+ length * 8
+ );
+ return new Uint8Array(bits);
+}
+
+export async function deriveAuthHash(master) {
+ const bytes = await hkdfBytes(master, AUTH_INFO);
+ return toHex(bytes);
+}
+
+// Returns a raw AES-GCM CryptoKey ready for encrypt/decrypt. The raw bytes
+// are intentionally NOT exposed — once derived they live only inside the
+// WebCrypto key handle for the lifetime of the session.
+export async function deriveVaultKey(master, saltV) {
+ const salt = typeof saltV === 'string' ? fromHex(saltV) : saltV;
+ const rawKey = await hkdfBytes(master, VAULT_INFO, salt, 32);
+ const subtle = subtleCrypto();
+ return subtle.importKey(
+ 'raw',
+ rawKey,
+ { name: 'AES-GCM' },
+ false, // non-extractable
+ ['encrypt', 'decrypt']
+ );
+}
+
+// Convenience: register/login flow. Returns both the hex authHash and the
+// master bytes (so the caller can later call deriveVaultKey(master, saltV)
+// without re-running the expensive PBKDF2).
+export async function deriveLoginKeys(password, email) {
+ const master = await deriveMaster(password, email);
+ const authHash = await deriveAuthHash(master);
+ return { master, authHash };
+}
+
+export const __internals = {
+ PBKDF2_ITERATIONS,
+ AUTH_INFO,
+ VAULT_INFO,
+ toHex,
+ fromHex,
+};
diff --git a/src/lib/crypto/kdf.test.js b/src/lib/crypto/kdf.test.js
new file mode 100644
index 0000000..c6182e7
--- /dev/null
+++ b/src/lib/crypto/kdf.test.js
@@ -0,0 +1,150 @@
+import {
+ deriveMaster,
+ deriveAuthHash,
+ deriveVaultKey,
+ deriveLoginKeys,
+ __internals,
+} from './kdf';
+
+const nodeCrypto = require('crypto');
+
+// Reference implementations using Node's crypto, independent of WebCrypto.
+// If our WebCrypto path drifts from this, tests fail — catching a contract
+// break before it ever ships.
+
+function nodePbkdf2(password, email, iterations = __internals.PBKDF2_ITERATIONS) {
+ return new Promise((resolve, reject) => {
+ nodeCrypto.pbkdf2(
+ password,
+ email.normalize('NFKC').trim().toLowerCase(),
+ iterations,
+ 32,
+ 'sha512',
+ (err, out) => (err ? reject(err) : resolve(new Uint8Array(out)))
+ );
+ });
+}
+
+function nodeHkdfSha256(master, info, salt = Buffer.alloc(0), length = 32) {
+ const ikm = Buffer.from(master);
+ // RFC 5869 HKDF: Extract + Expand.
+ const prk = nodeCrypto.createHmac('sha256', salt).update(ikm).digest();
+ let t = Buffer.alloc(0);
+ let okm = Buffer.alloc(0);
+ let i = 1;
+ while (okm.length < length) {
+ t = nodeCrypto
+ .createHmac('sha256', prk)
+ .update(Buffer.concat([t, Buffer.from(info, 'utf8'), Buffer.from([i])]))
+ .digest();
+ okm = Buffer.concat([okm, t]);
+ i += 1;
+ }
+ return new Uint8Array(okm.subarray(0, length));
+}
+
+describe('kdf.deriveMaster / deriveAuthHash — cross-check vs Node crypto', () => {
+ const password = 'correct horse battery staple';
+ const email = 'User@Example.com';
+
+ test('deriveMaster matches Node PBKDF2-SHA512 over NFKC+trim+lowercase email', async () => {
+ const [webMaster, nodeMaster] = await Promise.all([
+ deriveMaster(password, email),
+ nodePbkdf2(password, email),
+ ]);
+ expect(webMaster).toHaveLength(32);
+ expect(Array.from(webMaster)).toEqual(Array.from(nodeMaster));
+ }, 20000);
+
+ test('deriveAuthHash matches Node HKDF-SHA256 with info="sysnode-auth-v1"', async () => {
+ const master = await nodePbkdf2(password, email);
+ const authHash = await deriveAuthHash(master);
+
+ const expected = nodeHkdfSha256(master, __internals.AUTH_INFO);
+ const expectedHex = Buffer.from(expected).toString('hex');
+
+ expect(authHash).toBe(expectedHex);
+ expect(authHash).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ test('deriveLoginKeys returns both master and authHash for a single call', async () => {
+ const { master, authHash } = await deriveLoginKeys(password, email);
+ expect(master).toHaveLength(32);
+ expect(authHash).toMatch(/^[0-9a-f]{64}$/);
+ const direct = await deriveAuthHash(master);
+ expect(authHash).toBe(direct);
+ }, 20000);
+
+ test('email case and padding do not affect the derived master (normalization)', async () => {
+ const a = await nodePbkdf2(password, ' USER@example.COM ');
+ const b = await nodePbkdf2(password, 'user@example.com');
+ expect(Array.from(a)).toEqual(Array.from(b));
+ }, 20000);
+
+ test('empty password is rejected', async () => {
+ await expect(deriveMaster('', email)).rejects.toThrow(/password/i);
+ });
+
+ test('empty email is rejected', async () => {
+ await expect(deriveMaster(password, '')).rejects.toThrow(/email/i);
+ });
+});
+
+describe('kdf.deriveVaultKey', () => {
+ test('produces a non-extractable AES-GCM key that round-trips', async () => {
+ const master = new Uint8Array(32).fill(7);
+ const saltV = 'a'.repeat(64);
+ const key = await deriveVaultKey(master, saltV);
+ expect(key.algorithm.name).toBe('AES-GCM');
+ expect(key.extractable).toBe(false);
+
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
+ const plaintext = new TextEncoder().encode('hello vault');
+ const ct = await globalThis.crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv },
+ key,
+ plaintext
+ );
+ const pt = await globalThis.crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv },
+ key,
+ ct
+ );
+ expect(new TextDecoder().decode(pt)).toBe('hello vault');
+ });
+
+ test('rejects non-hex saltV instead of silently coercing partial parses (Codex round 2 P2)', async () => {
+ const master = new Uint8Array(32).fill(1);
+ // `Ax...` would parseInt to 10 (0xA) on the first two chars; the strict
+ // regex pre-check in fromHex must throw instead.
+ await expect(
+ deriveVaultKey(master, 'A'.repeat(62) + 'xx')
+ ).rejects.toThrow(/invalid hex/i);
+ // Odd-length hex: structurally malformed length.
+ await expect(deriveVaultKey(master, 'abc')).rejects.toThrow(/invalid hex/i);
+ // Trailing non-hex char inside a 2-char byte window (the exact case
+ // parseInt would otherwise silently coerce to 0xA).
+ await expect(deriveVaultKey(master, 'A0'.repeat(31) + 'AZ')).rejects.toThrow(
+ /invalid hex/i
+ );
+ });
+
+ test('different saltV values yield different keys (sanity)', async () => {
+ const master = new Uint8Array(32).fill(5);
+ const saltA = '11'.repeat(32);
+ const saltB = '22'.repeat(32);
+ const keyA = await deriveVaultKey(master, saltA);
+ const keyB = await deriveVaultKey(master, saltB);
+ // Keys are non-extractable, so we prove divergence by encrypting the
+ // same plaintext with a fixed IV and checking ciphertexts differ.
+ const iv = new Uint8Array(12);
+ const pt = new TextEncoder().encode('probe');
+ const ctA = new Uint8Array(
+ await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, keyA, pt)
+ );
+ const ctB = new Uint8Array(
+ await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, keyB, pt)
+ );
+ expect(Array.from(ctA)).not.toEqual(Array.from(ctB));
+ });
+});
diff --git a/src/lib/crypto/normalize.js b/src/lib/crypto/normalize.js
new file mode 100644
index 0000000..b44cf16
--- /dev/null
+++ b/src/lib/crypto/normalize.js
@@ -0,0 +1,35 @@
+// Email normalization MUST match sysnode-backend/lib/email.js exactly —
+// the email is the PBKDF2 salt, so a divergence here makes login impossible
+// across devices or after a cache clear.
+//
+// Rules (mirrored from the backend):
+// 1. NFKC compatibility decomposition + recomposition.
+// 2. Trim surrounding whitespace.
+// 3. Lowercase via the invariant Unicode mapping.
+
+export function normalizeEmail(raw) {
+ if (typeof raw !== 'string') return '';
+ return raw.normalize('NFKC').trim().toLowerCase();
+}
+
+// Permissive syntax check (also mirrored). Rejects obvious malformations but
+// leaves deliverability to the verification-link round trip.
+export function isValidEmailSyntax(value) {
+ if (typeof value !== 'string') return false;
+ if (value.length === 0 || value.length > 254) return false;
+ const atIdx = value.indexOf('@');
+ if (atIdx < 1 || atIdx !== value.lastIndexOf('@')) return false;
+ const local = value.slice(0, atIdx);
+ const domain = value.slice(atIdx + 1);
+ if (local.length === 0 || local.length > 64) return false;
+ if (/\s/.test(value)) return false;
+ if (domain.length === 0 || domain.indexOf('.') === -1) return false;
+ const labels = domain.split('.');
+ if (labels.some(function rejectBadLabel(l) {
+ return !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(l);
+ })) {
+ return false;
+ }
+ if (!/^[A-Za-z0-9._%+\-]+$/.test(local)) return false;
+ return true;
+}
diff --git a/src/lib/crypto/normalize.test.js b/src/lib/crypto/normalize.test.js
new file mode 100644
index 0000000..4ac04c0
--- /dev/null
+++ b/src/lib/crypto/normalize.test.js
@@ -0,0 +1,43 @@
+import { normalizeEmail, isValidEmailSyntax } from './normalize';
+
+describe('normalize.normalizeEmail', () => {
+ test('trims and lowercases', () => {
+ expect(normalizeEmail(' User@Example.COM ')).toBe('user@example.com');
+ });
+
+ test('applies NFKC', () => {
+ expect(normalizeEmail('\u{FB01}oo@bar.com')).toBe('fioo@bar.com');
+ });
+
+ test('is idempotent', () => {
+ const first = normalizeEmail('User@Example.com');
+ expect(normalizeEmail(first)).toBe(first);
+ });
+
+ test('returns empty string for non-strings', () => {
+ expect(normalizeEmail(null)).toBe('');
+ expect(normalizeEmail(undefined)).toBe('');
+ expect(normalizeEmail(42)).toBe('');
+ });
+});
+
+describe('normalize.isValidEmailSyntax', () => {
+ test('accepts common formats', () => {
+ expect(isValidEmailSyntax('a@b.co')).toBe(true);
+ expect(isValidEmailSyntax('user+tag@sub.example.com')).toBe(true);
+ });
+
+ test('rejects obvious malformations', () => {
+ expect(isValidEmailSyntax('')).toBe(false);
+ expect(isValidEmailSyntax('no-at-symbol')).toBe(false);
+ expect(isValidEmailSyntax('two@@at.com')).toBe(false);
+ expect(isValidEmailSyntax('space in@email.com')).toBe(false);
+ expect(isValidEmailSyntax('@nouser.com')).toBe(false);
+ expect(isValidEmailSyntax('nodot@test')).toBe(false);
+ });
+
+ test('accepts domains whose labels start with a digit', () => {
+ expect(isValidEmailSyntax('user@1domain.com')).toBe(true);
+ expect(isValidEmailSyntax('user@sub.1domain.com')).toBe(true);
+ });
+});
diff --git a/src/lib/crypto/vault.js b/src/lib/crypto/vault.js
new file mode 100644
index 0000000..ee40a75
--- /dev/null
+++ b/src/lib/crypto/vault.js
@@ -0,0 +1,132 @@
+// Vault payload format (v1):
+//
+// base64url(
+// "SYSV1" // 5-byte magic
+// | iv (12 bytes)
+// | ciphertext (|plaintext| bytes)
+// | tag (16 bytes, appended by AES-GCM)
+// )
+//
+// The plaintext is a UTF-8 JSON-serializable object. The server stores the
+// resulting base64url string verbatim; it is never decrypted server-side.
+//
+// Design notes:
+// - AES-GCM is used (authenticated encryption). 12-byte IV is the GCM default
+// and matches WebCrypto convention.
+// - base64url (no padding) keeps the blob HTTP-cookie/URL-safe and avoids
+// needing JSON escape on the outer transport.
+// - The magic prefix lets future versions (e.g. v2 with a different KDF or
+// cipher) be detected without ambiguity, and catches "someone pasted a
+// non-vault string into the PUT body" accidents.
+// - The AES-GCM key comes from `deriveVaultKey` in `./kdf`; this module
+// never touches the user's password directly.
+
+const MAGIC = new TextEncoder().encode('SYSV1');
+const IV_BYTES = 12;
+
+function subtleCrypto() {
+ const c =
+ (typeof globalThis !== 'undefined' && globalThis.crypto) ||
+ (typeof window !== 'undefined' && window.crypto);
+ if (!c || !c.subtle) {
+ throw new Error('WebCrypto is unavailable.');
+ }
+ return c;
+}
+
+function concat(a, b, c) {
+ const out = new Uint8Array(a.length + b.length + c.length);
+ out.set(a, 0);
+ out.set(b, a.length);
+ out.set(c, a.length + b.length);
+ return out;
+}
+
+// base64url without padding.
+function toBase64Url(bytes) {
+ let str = '';
+ for (let i = 0; i < bytes.length; i += 1) {
+ str += String.fromCharCode(bytes[i]);
+ }
+ const b64 =
+ typeof btoa === 'function'
+ ? btoa(str)
+ : Buffer.from(bytes).toString('base64');
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function fromBase64Url(str) {
+ if (typeof str !== 'string') throw new Error('blob must be a string');
+ const padLen = (4 - (str.length % 4)) % 4;
+ const b64 = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLen);
+ if (typeof atob === 'function') {
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
+ return out;
+ }
+ return new Uint8Array(Buffer.from(b64, 'base64'));
+}
+
+function matchesMagic(bytes) {
+ if (bytes.length < MAGIC.length) return false;
+ for (let i = 0; i < MAGIC.length; i += 1) {
+ if (bytes[i] !== MAGIC[i]) return false;
+ }
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// API
+// ---------------------------------------------------------------------------
+
+// Encrypt the vault data (arbitrary JSON-safe object) with the caller-provided
+// AES-GCM CryptoKey (produced by `deriveVaultKey`). Returns a base64url string
+// suitable for the `PUT /vault` body.
+export async function encryptVault(plaintextObject, aesGcmKey) {
+ const c = subtleCrypto();
+ const iv = c.getRandomValues(new Uint8Array(IV_BYTES));
+ const plaintext = new TextEncoder().encode(JSON.stringify(plaintextObject));
+ const ctWithTag = new Uint8Array(
+ await c.subtle.encrypt({ name: 'AES-GCM', iv }, aesGcmKey, plaintext)
+ );
+ return toBase64Url(concat(MAGIC, iv, ctWithTag));
+}
+
+// Decrypt a blob produced by `encryptVault`. Returns the parsed JSON value.
+// Throws on magic mismatch, truncation, or AES-GCM authentication failure
+// (e.g. wrong key, tampered blob).
+export async function decryptVault(blob, aesGcmKey) {
+ const all = fromBase64Url(blob);
+ if (!matchesMagic(all)) {
+ const err = new Error('invalid_vault_magic');
+ err.code = 'invalid_vault_magic';
+ throw err;
+ }
+ if (all.length < MAGIC.length + IV_BYTES + 16) {
+ const err = new Error('vault_truncated');
+ err.code = 'vault_truncated';
+ throw err;
+ }
+ const iv = all.slice(MAGIC.length, MAGIC.length + IV_BYTES);
+ const ctWithTag = all.slice(MAGIC.length + IV_BYTES);
+
+ const c = subtleCrypto();
+ let pt;
+ try {
+ pt = await c.subtle.decrypt({ name: 'AES-GCM', iv }, aesGcmKey, ctWithTag);
+ } catch (e) {
+ const err = new Error('vault_decrypt_failed');
+ err.code = 'vault_decrypt_failed';
+ throw err;
+ }
+ try {
+ return JSON.parse(new TextDecoder().decode(pt));
+ } catch (e) {
+ const err = new Error('vault_invalid_json');
+ err.code = 'vault_invalid_json';
+ throw err;
+ }
+}
+
+export const __internals = { toBase64Url, fromBase64Url, MAGIC, IV_BYTES };
diff --git a/src/lib/crypto/vault.test.js b/src/lib/crypto/vault.test.js
new file mode 100644
index 0000000..80e1f68
--- /dev/null
+++ b/src/lib/crypto/vault.test.js
@@ -0,0 +1,77 @@
+import { encryptVault, decryptVault, __internals } from './vault';
+import { deriveVaultKey } from './kdf';
+
+async function makeKey(fillByte = 1, saltByte = 2) {
+ const master = new Uint8Array(32).fill(fillByte);
+ const saltV = saltByte.toString(16).padStart(2, '0').repeat(32);
+ return deriveVaultKey(master, saltV);
+}
+
+describe('vault encryption', () => {
+ test('round-trips an object through encrypt + decrypt', async () => {
+ const key = await makeKey();
+ const data = {
+ version: 1,
+ keys: [{ label: 'Node 1', wif: 'abc' }],
+ };
+ const blob = await encryptVault(data, key);
+
+ expect(typeof blob).toBe('string');
+ expect(blob).toMatch(/^[A-Za-z0-9_-]+$/);
+
+ const decoded = await decryptVault(blob, key);
+ expect(decoded).toEqual(data);
+ });
+
+ test('each encryption produces a different ciphertext (fresh IV)', async () => {
+ const key = await makeKey();
+ const data = { foo: 'bar' };
+ const a = await encryptVault(data, key);
+ const b = await encryptVault(data, key);
+ expect(a).not.toBe(b);
+ });
+
+ test('blob carries the SYSV1 magic prefix', async () => {
+ const key = await makeKey();
+ const blob = await encryptVault({ x: 1 }, key);
+ const bytes = __internals.fromBase64Url(blob);
+ expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe('SYSV1');
+ });
+
+ test('decrypt fails with the wrong key', async () => {
+ const keyA = await makeKey(1, 1);
+ const keyB = await makeKey(2, 2);
+ const blob = await encryptVault({ x: 1 }, keyA);
+ await expect(decryptVault(blob, keyB)).rejects.toMatchObject({
+ code: 'vault_decrypt_failed',
+ });
+ });
+
+ test('decrypt rejects a tampered blob', async () => {
+ const key = await makeKey();
+ const blob = await encryptVault({ hello: 'world' }, key);
+ const bytes = __internals.fromBase64Url(blob);
+ bytes[bytes.length - 1] ^= 0xff;
+ const tampered = __internals.toBase64Url(bytes);
+ await expect(decryptVault(tampered, key)).rejects.toMatchObject({
+ code: 'vault_decrypt_failed',
+ });
+ });
+
+ test('decrypt rejects a blob without the magic prefix', async () => {
+ const key = await makeKey();
+ const junk = __internals.toBase64Url(new Uint8Array([9, 9, 9, 9, 9, 1, 2, 3]));
+ await expect(decryptVault(junk, key)).rejects.toMatchObject({
+ code: 'invalid_vault_magic',
+ });
+ });
+
+ test('decrypt rejects a truncated blob', async () => {
+ const key = await makeKey();
+ const blob = await encryptVault({ x: 1 }, key);
+ const truncated = blob.slice(0, 10);
+ await expect(decryptVault(truncated, key)).rejects.toMatchObject({
+ code: expect.stringMatching(/invalid_vault_magic|vault_truncated/),
+ });
+ });
+});
diff --git a/src/pages/Account.js b/src/pages/Account.js
new file mode 100644
index 0000000..e10bc3e
--- /dev/null
+++ b/src/pages/Account.js
@@ -0,0 +1,96 @@
+import React, { useState } from 'react';
+import { useHistory } from 'react-router-dom';
+
+import PageMeta from '../components/PageMeta';
+import { useAuth } from '../context/AuthContext';
+
+export default function Account() {
+ const { user, logout } = useAuth();
+ const history = useHistory();
+ const [signOutError, setSignOutError] = useState(null);
+ const [signingOut, setSigningOut] = useState(false);
+
+ async function onSignOut() {
+ // The AuthContext now surfaces `logout_failed` when the server call
+ // fails on anything other than 401/404 (session already gone). In
+ // that case the session cookie is still valid server-side, so we
+ // must NOT redirect to /login — doing so would tell the user they
+ // were signed out while a reload would restore the session.
+ setSignOutError(null);
+ setSigningOut(true);
+ try {
+ await logout();
+ history.replace('/login');
+ } catch (err) {
+ setSignOutError(
+ err && err.code === 'logout_failed'
+ ? "We couldn't end your session on the server. Please retry, or close this browser window to be safe."
+ : 'Sign out failed. Please retry.'
+ );
+ } finally {
+ setSigningOut(false);
+ }
+ }
+
+ return (
+ <>
+
+
+
+ Account
+
Your Sysnode account
+
+ Your voting vault and Sentry Node tooling live here. More controls
+ are landing soon — key import, vote history, and reminder
+ preferences are on the way.
+
+
+ >
+ );
+}
diff --git a/src/pages/Login.js b/src/pages/Login.js
new file mode 100644
index 0000000..d57a042
--- /dev/null
+++ b/src/pages/Login.js
@@ -0,0 +1,143 @@
+import React, { useState } from 'react';
+import { Link, useHistory, useLocation } from 'react-router-dom';
+
+import PageMeta from '../components/PageMeta';
+import { useAuth } from '../context/AuthContext';
+import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize';
+
+const ERROR_COPY = {
+ invalid_credentials:
+ "We couldn't sign you in with that email and password. Double-check for typos and try again.",
+ email_not_verified:
+ 'Your email isn\'t verified yet. Check your inbox for the verification link, or register again to resend it.',
+ network_error:
+ 'We couldn\'t reach the sysnode server. Check your connection and try again.',
+ server_misconfigured:
+ 'The sysnode server is temporarily unavailable. Please try again in a moment.',
+ invalid_body:
+ 'Please enter a valid email and password.',
+ session_not_established:
+ "Your sign-in went through, but your browser didn't keep the session cookie. If you're using strict / third-party-cookie blocking for this site, allow it for sysnode and try again.",
+};
+
+function errorToCopy(code) {
+ return ERROR_COPY[code] || 'Something went wrong. Please try again.';
+}
+
+export default function Login() {
+ const { login } = useAuth();
+ const history = useHistory();
+ const location = useLocation();
+
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ function onSubmit(event) {
+ event.preventDefault();
+ if (submitting) return;
+
+ const normalized = normalizeEmail(email);
+ if (!isValidEmailSyntax(normalized)) {
+ setError({ code: 'invalid_email', message: 'Please enter a valid email address.' });
+ return;
+ }
+ if (password.length < 8) {
+ setError({
+ code: 'password_too_short',
+ message: 'Passwords are at least 8 characters.',
+ });
+ return;
+ }
+
+ setSubmitting(true);
+ setError(null);
+ login({ email: normalized, password })
+ .then(function onLoggedIn() {
+ const next = (location.state && location.state.from) || '/account';
+ history.replace(next);
+ })
+ .catch(function onLoginError(err) {
+ setError({ code: err.code || 'unknown', message: errorToCopy(err.code) });
+ })
+ .finally(function always() {
+ setSubmitting(false);
+ });
+ }
+
+ return (
+ <>
+
+
+
+ Account
+
Sign in
+
+ Sign in to manage Sentry Node voting keys and vote on Syscoin
+ governance proposals from any device.
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/pages/Login.test.js b/src/pages/Login.test.js
new file mode 100644
index 0000000..5026b5b
--- /dev/null
+++ b/src/pages/Login.test.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Switch } from 'react-router-dom';
+
+import Login from './Login';
+import { AuthProvider } from '../context/AuthContext';
+
+jest.mock('../components/PageMeta', () => function MockPageMeta() {
+ return null;
+});
+
+function renderLogin(service, { initialPath = '/login' } = {}) {
+ return render(
+
+
+
+
+
ACCOUNT PAGE
} />
+
+
+
+ );
+}
+
+function mockService(overrides = {}) {
+ return {
+ me: jest.fn().mockRejectedValue(
+ Object.assign(new Error('unauth'), { status: 401 })
+ ),
+ login: jest.fn(),
+ logout: jest.fn(),
+ register: jest.fn(),
+ verifyEmail: jest.fn(),
+ ...overrides,
+ };
+}
+
+test('rejects an obviously invalid email before calling the service', async () => {
+ const service = mockService();
+ renderLogin(service);
+ await waitFor(() => expect(service.me).toHaveBeenCalled());
+
+ await userEvent.type(screen.getByLabelText(/email/i), 'not-an-email');
+ await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a');
+ await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ /valid email address/i
+ );
+ expect(service.login).not.toHaveBeenCalled();
+});
+
+test('renders a friendly message when the server returns invalid_credentials', async () => {
+ const service = mockService({
+ login: jest
+ .fn()
+ .mockRejectedValue(
+ Object.assign(new Error('invalid_credentials'), {
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ ),
+ });
+ renderLogin(service);
+ await waitFor(() => expect(service.me).toHaveBeenCalled());
+
+ await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com');
+ await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a');
+ await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ /couldn't sign you in/i
+ );
+});
+
+test('sends the user to /account on successful login', async () => {
+ const service = mockService({
+ login: jest.fn().mockResolvedValue({
+ user: { id: 1, email: 'a@b.com' },
+ expiresAt: 1,
+ }),
+ me: jest
+ .fn()
+ .mockRejectedValueOnce(
+ Object.assign(new Error('unauth'), { status: 401 })
+ )
+ .mockResolvedValueOnce({
+ user: { id: 1, email: 'a@b.com', emailVerified: true },
+ }),
+ });
+ renderLogin(service);
+ await waitFor(() => expect(service.me).toHaveBeenCalledTimes(1));
+
+ await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com');
+ await userEvent.type(screen.getByLabelText(/password/i), 'hunter22a');
+ await act(async () => {
+ await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
+ });
+
+ await waitFor(() => expect(screen.getByText('ACCOUNT PAGE')).toBeInTheDocument());
+ expect(service.login).toHaveBeenCalledWith('a@b.com', 'hunter22a');
+});
diff --git a/src/pages/Register.js b/src/pages/Register.js
new file mode 100644
index 0000000..15370af
--- /dev/null
+++ b/src/pages/Register.js
@@ -0,0 +1,221 @@
+import React, { useState } from 'react';
+import { Link } from 'react-router-dom';
+
+import PageMeta from '../components/PageMeta';
+import { useAuth } from '../context/AuthContext';
+import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize';
+
+const ERROR_COPY = {
+ invalid_email: 'That email address doesn\'t look right — please check and try again.',
+ network_error:
+ 'We couldn\'t reach the sysnode server. Check your connection and try again.',
+ server_misconfigured:
+ 'The sysnode server is temporarily unavailable. Please try again in a moment.',
+ invalid_body: 'Please enter a valid email and a password of at least 8 characters.',
+};
+
+function errorToCopy(code) {
+ return ERROR_COPY[code] || 'Something went wrong. Please try again.';
+}
+
+// We intentionally enforce only a length floor on the client. The server
+// never sees the password directly — it only receives the PBKDF2+HKDF
+// output — so enforcing arbitrary character-class rules would add friction
+// without buying anything. Users who want stronger passwords are welcome
+// to use longer ones; the KDF work factor cushions the rest.
+const MIN_PASSWORD_LEN = 8;
+
+export default function Register() {
+ const { register } = useAuth();
+
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [submittedTo, setSubmittedTo] = useState(null);
+
+ function onSubmit(event) {
+ event.preventDefault();
+ if (submitting) return;
+
+ const normalized = normalizeEmail(email);
+ if (!isValidEmailSyntax(normalized)) {
+ setError({
+ code: 'invalid_email',
+ message: 'Please enter a valid email address.',
+ });
+ return;
+ }
+ if (password.length < MIN_PASSWORD_LEN) {
+ setError({
+ code: 'password_too_short',
+ message: `Password must be at least ${MIN_PASSWORD_LEN} characters.`,
+ });
+ return;
+ }
+ if (password !== confirm) {
+ setError({
+ code: 'password_mismatch',
+ message: 'The passwords you entered don\'t match.',
+ });
+ return;
+ }
+
+ setSubmitting(true);
+ setError(null);
+
+ register({ email: normalized, password })
+ .then(function onRegistered() {
+ setSubmittedTo(normalized);
+ })
+ .catch(function onRegistrationError(err) {
+ setError({ code: err.code || 'unknown', message: errorToCopy(err.code) });
+ })
+ .finally(function always() {
+ setSubmitting(false);
+ });
+ }
+
+ if (submittedTo) {
+ return (
+ <>
+
+
+
+ Almost there
+
Check your inbox
+
+ If the address you entered is valid, we've sent a verification
+ link to {submittedTo}. Click the link within
+ 30 minutes to finish creating your Sysnode account.
+
+
+
+
+
+
+
+ Didn't get the email? Check your spam folder, then{' '}
+
+ . Each new request issues a fresh link; only the most recent
+ one you click will be redeemed.
+
+
+ Already verified? Sign in
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+ Account
+
Create your account
+
+ Your password derives a key in your browser — Sysnode never sees
+ or stores it. Choose something you'll remember, because a lost
+ password means a lost voting vault.
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/pages/Register.test.js b/src/pages/Register.test.js
new file mode 100644
index 0000000..0c111b6
--- /dev/null
+++ b/src/pages/Register.test.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+
+import Register from './Register';
+import { AuthProvider } from '../context/AuthContext';
+
+jest.mock('../components/PageMeta', () => function MockPageMeta() {
+ return null;
+});
+
+function renderRegister(service) {
+ return render(
+
+
+
+
+
+ );
+}
+
+function mockService(overrides = {}) {
+ return {
+ me: jest.fn().mockRejectedValue(
+ Object.assign(new Error('unauth'), { status: 401 })
+ ),
+ login: jest.fn(),
+ logout: jest.fn(),
+ register: jest.fn().mockResolvedValue({ status: 'verification_sent' }),
+ verifyEmail: jest.fn(),
+ ...overrides,
+ };
+}
+
+test('validates password length and mismatch client-side', async () => {
+ const service = mockService();
+ renderRegister(service);
+
+ await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com');
+ await userEvent.type(screen.getByLabelText(/^password/i), 'short');
+ await userEvent.type(screen.getByLabelText(/confirm password/i), 'short');
+ await userEvent.click(screen.getByRole('button', { name: /create account/i }));
+ expect(await screen.findByRole('alert')).toHaveTextContent(/at least 8/i);
+ expect(service.register).not.toHaveBeenCalled();
+
+ const pw = screen.getByLabelText(/^password/i);
+ const cf = screen.getByLabelText(/confirm password/i);
+ await userEvent.clear(pw);
+ await userEvent.clear(cf);
+ await userEvent.type(pw, 'hunter22a');
+ await userEvent.type(cf, 'hunter22b');
+ await userEvent.click(screen.getByRole('button', { name: /create account/i }));
+ expect(await screen.findByRole('alert')).toHaveTextContent(/don'?t match/i);
+ expect(service.register).not.toHaveBeenCalled();
+});
+
+test('shows the "check your inbox" screen on success', async () => {
+ const service = mockService();
+ renderRegister(service);
+
+ await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com');
+ await userEvent.type(screen.getByLabelText(/^password/i), 'hunter22a');
+ await userEvent.type(screen.getByLabelText(/confirm password/i), 'hunter22a');
+ await userEvent.click(screen.getByRole('button', { name: /create account/i }));
+
+ await waitFor(() =>
+ expect(screen.getByText(/check your inbox/i)).toBeInTheDocument()
+ );
+ expect(service.register).toHaveBeenCalledWith('a@b.com', 'hunter22a');
+});
diff --git a/src/pages/VerifyEmail.js b/src/pages/VerifyEmail.js
new file mode 100644
index 0000000..48f59af
--- /dev/null
+++ b/src/pages/VerifyEmail.js
@@ -0,0 +1,169 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+
+import PageMeta from '../components/PageMeta';
+import { useAuth } from '../context/AuthContext';
+
+const STATUS_BOOTING = 'verifying';
+const STATUS_OK = 'verified';
+const STATUS_BAD = 'invalid';
+const STATUS_ALREADY = 'already_verified';
+const STATUS_ERROR = 'error';
+
+function statusFromError(err) {
+ switch (err && err.code) {
+ case 'invalid_or_expired_token':
+ case 'invalid_token':
+ return STATUS_BAD;
+ case 'already_verified':
+ return STATUS_ALREADY;
+ default:
+ return STATUS_ERROR;
+ }
+}
+
+export default function VerifyEmail() {
+ const { verifyEmail } = useAuth();
+ const location = useLocation();
+ const [status, setStatus] = useState(STATUS_BOOTING);
+
+ // We dedupe by the token value itself rather than a one-shot boolean.
+ //
+ // A one-shot guard protects against StrictMode's intentional effect
+ // double-invoke (each token is single-use server-side, so double
+ // redeem would burn a still-valid link). But it ALSO locks the page
+ // to whatever token it first saw — in a same-tab SPA navigation from
+ // `/verify-email?token=A` to `?token=B` the effect re-fires with
+ // fresh `location.search`, and a never-reset boolean would skip the
+ // new redemption silently, leaving stale status on screen until a
+ // hard reload. (Codex round 1 P2.)
+ //
+ // Keying the guard on the token value gives us StrictMode safety
+ // (second invoke with the same token is a no-op) AND correctness
+ // across in-app token changes (different token, different ref value,
+ // new redeem fires).
+ const lastSubmittedTokenRef = useRef(null);
+ // Monotonic request counter. Each dispatch captures the counter at
+ // its start; the .then/.catch only applies setStatus if the counter
+ // hasn't advanced in the meantime. Prevents a late response from a
+ // previous token from overwriting the status for whichever token is
+ // currently on screen. (Codex round 4 P2.)
+ const reqIdRef = useRef(0);
+
+ useEffect(
+ function runVerification() {
+ const params = new URLSearchParams(location.search);
+ const token = (params.get('token') || '').trim();
+
+ if (lastSubmittedTokenRef.current === token) return;
+ lastSubmittedTokenRef.current = token;
+
+ // Bump the counter BEFORE the malformed-token early return.
+ //
+ // If a previous valid-token request is still in flight and the
+ // URL changes to a malformed token, we want that stale response
+ // to no-op when it lands. Previously the counter only advanced
+ // on dispatch, so a late-returning "verified" would satisfy
+ // `myReqId === reqIdRef.current` and overwrite the current
+ // invalid-token screen. Unconditionally bumping on any new
+ // token (valid or not) makes every older in-flight request
+ // stale from this point. (Codex round 6 P2.)
+ const myReqId = (reqIdRef.current += 1);
+
+ if (!/^[0-9a-f]{64}$/.test(token)) {
+ setStatus(STATUS_BAD);
+ return;
+ }
+
+ setStatus(STATUS_BOOTING);
+ verifyEmail(token)
+ .then(function onVerified() {
+ if (myReqId !== reqIdRef.current) return;
+ setStatus(STATUS_OK);
+ })
+ .catch(function onFailed(err) {
+ if (myReqId !== reqIdRef.current) return;
+ setStatus(statusFromError(err));
+ });
+ },
+ [location.search, verifyEmail]
+ );
+
+ return (
+ <>
+
+
+
+ Account
+ {status === STATUS_BOOTING ?
Verifying your email...
: null}
+ {status === STATUS_OK ?
Email verified
: null}
+ {status === STATUS_BAD ?
Verification link expired
: null}
+ {status === STATUS_ALREADY ?
Already verified
: null}
+ {status === STATUS_ERROR ?
Something went wrong
: null}
+
+
+
+
+
+
+ {status === STATUS_BOOTING ? (
+
+ Hang tight — this should take just a moment.
+
+ ) : null}
+
+ {status === STATUS_OK ? (
+ <>
+
+ Your email is confirmed and your Sysnode account is ready.
+