Skip to content
Open
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
772 changes: 8 additions & 764 deletions backend/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"node-fetch": "^3.3.2",
"p-queue": "^9.3.0",
"pino": "^9.3.2",

"pino-http": "^10.3.0",
"rate-limit-redis": "^4.2.0"
},
"engines": {
"node": ">=22.0.0"
Expand Down
13 changes: 0 additions & 13 deletions backend/src/routes/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,6 @@ function normalizePriceUsdc(value) {
return normalized;
}

/**
* Annotate a service entry with a ttl_warning flag.
* Returns true when the estimated remaining TTL falls below
* SERVICE_TTL_WARNING_LEDGERS. Omits the field when currentLedger
* is unavailable so callers treat absence as "no warning data".
*/
function annotateTtlWarning(service, currentLedger) {
if (currentLedger == null) return service;
const expiry = service.registered_at + SERVICE_MAX_TTL;
const warnOnset = expiry - SERVICE_TTL_WARNING_LEDGERS;
return { ...service, ttl_warning: currentLedger >= warnOnset };
}

function parsePositiveSafeInteger(value) {
if (typeof value === "number") {
return Number.isSafeInteger(value) && value > 0 ? value : null;
Expand Down
19 changes: 14 additions & 5 deletions backend/test/demo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ vi.mock("../src/routes/demoValidate.js", () => ({
validateDemoEndpoint: vi.fn().mockReturnValue("http://localhost:9999/demo"),
}));

// demo.js imports services.js, which constructs a real x402 HTTP resource
// server at module load; stub it so no network/server initialization runs.
vi.mock("../src/routes/services.js", () => ({
recordActivity: vi.fn(),
getActivityFeed: vi.fn(() => []),
}));

vi.mock("@x402/core/client", () => {
return {
x402Client: vi.fn().mockImplementation(() => ({
Expand All @@ -37,6 +44,7 @@ app.use("/api", demoRouter);

describe("POST /api/demo-run", () => {
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

Expand All @@ -49,14 +57,15 @@ describe("POST /api/demo-run", () => {
it("handles AbortError appropriately", async () => {
contract.getService.mockResolvedValue({ name: "Test Service", endpoint: "test", price_usdc: "1" });

// We mock fetchWithTx to throw an AbortError to simulate client cancelling the request
const { x402HTTPClient } = await import("@x402/core/client");
x402HTTPClient.mockImplementationOnce(() => ({
fetchWithTx: vi.fn().mockRejectedValue(Object.assign(new Error("aborted"), { name: "AbortError" })),
}));
// demo.js's fetchWithTx implementation uses the global fetch, so stub it
// to reject with an AbortError to simulate the client cancelling the request.
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(
Object.assign(new Error("aborted"), { name: "AbortError" })
));

const res = await request(app).post("/api/demo-run").send({ serviceId: 1, category: "weather" });
expect(res.status).toBe(499);
expect(res.body.code).toBe("CANCELLED");
vi.unstubAllGlobals();
});
});
4 changes: 2 additions & 2 deletions contract/Cargo.lock

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

4 changes: 4 additions & 0 deletions frontend/__tests__/Navbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ jest.mock('../components/WalletConnect', () => {
return MockWalletConnect;
});

jest.mock('../components/ThemeProvider', () => ({
useTheme: () => ({ theme: 'light', toggleTheme: jest.fn() }),
}));

function setPathname(path: string) {
mockPathname = path;
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/__tests__/RegisterForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ describe('RegisterForm validation', () => {
});

it('trims whitespace when validating name', async () => {
render(<RegisterForm walletAddress="GTESTADDRESS1234567890ABCDEFGHIJ" });
render(<RegisterForm walletAddress="GTESTADDRESS1234567890ABCDEFGHIJ" />);

const nameInput = screen.getByLabelText(/service name/i);
fireEvent.change(nameInput, { target: { value: ' ab ' } });
Expand All @@ -239,7 +239,7 @@ describe('RegisterForm validation', () => {
});

it('trims whitespace when validating endpoint', async () => {
render(<RegisterForm walletAddress="GTESTADDRESS1234567890ABCDEFGHIJ" });
render(<RegisterForm walletAddress="GTESTADDRESS1234567890ABCDEFGHIJ" />);

const endpointInput = screen.getByLabelText(/endpoint url/i);
fireEvent.change(endpointInput, { target: { value: ' http://example.com ' } });
Expand Down
126 changes: 126 additions & 0 deletions frontend/__tests__/contrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Automated WCAG AA contrast-ratio checks.
*
* After any design-token change these assertions will immediately flag
* colours that drop below the AA threshold, preventing silent regressions.
*
* WCAG 2.1 reference: https://www.w3.org/TR/WCAG21/#contrast-minimum
* Normal text (< 18pt / < 14pt bold): 4.5 : 1
* Large text (≥ 18pt / ≥ 14pt bold): 3.0 : 1
*/

/* ------------------------------------------------------------------ */
/* Relative luminance helpers (sRGB, WCAG formula) */
/* ------------------------------------------------------------------ */

function toLinear(c: number): number {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
}

function relativeLuminance(hex: string): number {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}

function contrastRatio(fg: string, bg: string): number {
const l1 = relativeLuminance(fg);
const l2 = relativeLuminance(bg);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}

/* ------------------------------------------------------------------ */
/* Design tokens (must stay in sync with tailwind.config.ts) */
/* ------------------------------------------------------------------ */

const BACKGROUND = '#FAFAF7';
const FOREGROUND = '#1A1A1A';
const WHITE = '#FFFFFF';

// Token → hex value (as defined in globals.css & tailwind.config.ts)
const TOKENS: Record<string, string> = {
primary: '#1A1A1A',
secondary: '#6B6B6B',
accent: '#C2410C',
border: '#E5E5E5',
success: '#15803D',
error: '#DC2626',
background: '#FAFAF7',
};
Comment thread
Olajcodes marked this conversation as resolved.

/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */

describe('WCAG AA colour contrast', () => {
// ── Text on background ───────────────────────────────────────────
describe('text on background (#FAFAF7)', () => {
const pairs: [string, string, number][] = [
// [token, bg, minimum expected ratio]
['primary', BACKGROUND, 4.5],
['secondary', BACKGROUND, 4.5],
['accent', BACKGROUND, 4.5],
['success', BACKGROUND, 4.5],
['error', BACKGROUND, 4.5],
];

test.each(pairs)('%s on bg (#FAFAF7)', (token, bg, min) => {
const hex = TOKENS[token];
const ratio = contrastRatio(hex, bg);
expect(ratio).toBeGreaterThanOrEqual(min);
});
});

// ── White text on coloured backgrounds (buttons, badges, etc.) ───
describe('white text on coloured backgrounds', () => {
const pairs: [string, number][] = [
['primary', 4.5],
['accent', 4.5],
['success', 4.5],
['error', 4.5],
];

test.each(pairs)('white on %s', (token, min) => {
const bg = TOKENS[token];
const ratio = contrastRatio(WHITE, bg);
expect(ratio).toBeGreaterThanOrEqual(min);
});
});

// ── Foreground text on white (cards) ─────────────────────────────
describe('text on white (#FFFFFF)', () => {
const pairs: [string, number][] = [
['primary', 4.5],
['secondary', 4.5],
['accent', 4.5],
['success', 4.5],
['error', 4.5],
];

test.each(pairs)('%s on white', (token, min) => {
const hex = TOKENS[token];
const ratio = contrastRatio(hex, WHITE);
expect(ratio).toBeGreaterThanOrEqual(min);
});
});

// ── Large-text pairings (≥ 3:1 threshold) ────────────────────────
describe('large-text pairings (≥ 3:1)', () => {
test('accent on background meets large-text threshold', () => {
// accent is used at text-lg (18px) for hero headings
const ratio = contrastRatio(TOKENS.accent, BACKGROUND);
expect(ratio).toBeGreaterThanOrEqual(3.0);
});
});

// ── Sanity: all tokens are defined ────────────────────────────────
test('all required tokens have hex values', () => {
for (const [name, hex] of Object.entries(TOKENS)) {
expect(hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
}
});
});
4 changes: 2 additions & 2 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
:root {
--background: #FAFAF7;
--primary: #1A1A1A;
--accent: #E85D3A;
--accent: #C2410C;
--secondary: #6B6B6B;
--border: #E5E5E5;
}
Expand Down Expand Up @@ -66,7 +66,7 @@ body {
}

.input {
@apply bg-background border border-border rounded-lg px-4 py-2.5 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1 focus:ring-primary/30 transition-shadow;
@apply bg-background border border-border rounded-lg px-4 py-2.5 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1 focus:ring-primary transition-shadow;
Comment thread
Olajcodes marked this conversation as resolved.
}
}

Expand Down
23 changes: 16 additions & 7 deletions frontend/components/RegisterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,13 @@ export default function RegisterForm({ walletAddress }: Props) {
return (
<form onSubmit={handleSubmit} className="card p-8 space-y-5 fade-in">
<Field
id="service-name"
label="Service Name"
error={errors.name}
hint="3–50 characters"
>
<input
id="service-name"
type="text"
value={form.name}
onChange={(e) => set('name', e.target.value)}
Expand All @@ -165,11 +167,13 @@ export default function RegisterForm({ walletAddress }: Props) {
</Field>

<Field
id="description"
label="Description"
error={errors.description}
hint="10–200 characters"
>
<textarea
id="description"
rows={3}
value={form.description}
onChange={(e) => set('description', e.target.value)}
Expand All @@ -180,11 +184,13 @@ export default function RegisterForm({ walletAddress }: Props) {
</Field>

<Field
id="endpoint"
label="Endpoint URL"
error={errors.endpoint}
hint="Must start with https://"
>
<input
id="endpoint"
type="url"
value={form.endpoint}
onChange={(e) => set('endpoint', e.target.value)}
Expand All @@ -195,11 +201,11 @@ export default function RegisterForm({ walletAddress }: Props) {
</Field>

<div className="grid grid-cols-2 gap-4">
<Field label="Price (USDC)" error={errors.price_usdc} hint="Min 0.0001">
<Field id="price-usdc" label="Price (USDC)" error={errors.price_usdc} hint="Min 0.0001">
<input
type="number"
step="0.0001"
min="0.0001"
id="price-usdc"
type="text"
inputMode="decimal"
value={form.price_usdc}
onChange={(e) => set('price_usdc', e.target.value)}
placeholder="0.001"
Expand All @@ -208,8 +214,9 @@ export default function RegisterForm({ walletAddress }: Props) {
/>
</Field>

<Field label="Category" error={errors.category}>
<Field id="category" label="Category" error={errors.category}>
<select
id="category"
value={form.category}
onChange={(e) => set('category', e.target.value as Category)}
disabled={submitting}
Expand All @@ -230,7 +237,7 @@ export default function RegisterForm({ walletAddress }: Props) {

<button
type="submit"
disabled={submitting || Object.keys(errors).length > 0}
disabled={submitting || Object.keys(validate(form)).length > 0}
className="btn-primary w-full py-3 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Registering…' : 'Register Service'}
Expand All @@ -248,11 +255,13 @@ function input(hasError: boolean) {
}

function Field({
id,
label,
error,
hint,
children,
}: {
id: string;
label: string;
error?: string;
hint?: string;
Expand All @@ -261,7 +270,7 @@ function Field({
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{label}</label>
<label htmlFor={id} className="text-sm font-medium">{label}</label>
{hint && !error && <span className="text-xs text-secondary">{hint}</span>}
{error && <span className="text-xs text-error">{error}</span>}
</div>
Expand Down
4 changes: 0 additions & 4 deletions frontend/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,6 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};

if (!mounted) {
return <>{children}</>;
}

return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
Expand Down
5 changes: 3 additions & 2 deletions frontend/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ const config: Config = {
accent: 'var(--accent)',
secondary: 'var(--secondary)',
border: 'var(--border)',
success: '#22C55E',
error: '#EF4444',
// WCAG AA contrast fixes (issue #382) — must stay ≥ 4.5:1 on light bg
success: '#15803D',
error: '#DC2626',
Comment thread
Olajcodes marked this conversation as resolved.
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
Expand Down
Loading