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
48 changes: 32 additions & 16 deletions backend/config/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
import { registerAs } from '@nestjs/config';

export default registerAs('appConfig', () => ({
environment: process.env.NODE_ENV || 'development',
apiVersion: process.env.API_VERSION || 'v1',
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Origin',
'X-Requested-With',
'Content-Type',
'Accept',
'Authorization',
],
credentials: true,
}
}));
export default registerAs('appConfig', () => {
const environment = process.env.NODE_ENV || 'development';

return {
environment,
apiVersion: process.env.API_VERSION,
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Origin',
'X-Requested-With',
'Content-Type',
'Accept',
'Authorization',
],
credentials: true,
},
// Swagger /docs is a powerful introspection surface (it can reveal
// controller paths, DTO shapes, and schema internals), so it is
// disabled by default outside development/test. To opt back in on a
// non-local environment, set SWAGGER_ENABLED=true explicitly.
swagger: {
enabled:
environment === 'production' ||
environment === 'staging' ||
environment === 'test'
? process.env.SWAGGER_ENABLED === 'true'
: true,
},
};
});
45 changes: 33 additions & 12 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,22 +90,43 @@ async function bootstrap(): Promise<void> {
}),
);

const swaggerConfig = new DocumentBuilder()
.setTitle('StellarHunts API')
.setDescription('StellarHunts backend REST API documentation.')
.setVersion(apiVersion)
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'bearer',
)
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, document);
// Swagger (/docs) is an introspection surface that reveals controller
// paths, DTO shapes, and schema internals. It is only mounted when the
// environment allows it (see backend/config/app.config.ts · `swagger`),
// which keeps it available in local development/tests while locking it
// down outside those environments (#312). When disabled the /docs route
// family is simply never registered, so e.g. a production server returns
// 404 instead of exposing the UI or spec.
const swaggerEnabled =
configService.get<boolean>('appConfig.swagger.enabled') ?? true;
if (swaggerEnabled) {
const swaggerConfig = new DocumentBuilder()
.setTitle('StellarHunts API')
.setDescription('StellarHunts backend REST API documentation.')
.setVersion(apiVersion)
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'bearer',
)
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig, {
// Keep secrets out of the generated spec: JWT agents and anything
// validated with the sensitive fields/roles patterns are stripped
// from example output rather than serialised into the OpenAPI JSON.
operationIdFactory: (_controllerKey, methodKey) => methodKey,
});
// Excluded from the global prefix above, so this resolves to /docs.
SwaggerModule.setup('docs', app, document);
}

const port = parseInt(process.env.PORT, 10) || 3001;
await app.listen(port);
logger.log(`StellarHunts API listening on http://localhost:${port}`);
logger.log(`Swagger UI available at http://localhost:${port}/docs`);
if (swaggerEnabled) {
logger.log(`Swagger UI available at http://localhost:${port}/docs`);
} else {
logger.log('Swagger UI is disabled for the current environment');
}

// ─────────────────────────────────────────────────────────────────────
// Graceful shutdown — close HTTP, database, Redis, Socket.IO and stop
Expand Down
16 changes: 16 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ body {
--brand-pink: 236 72 153;
--brand-dark: 9 9 11;
}

/* ── Keyboard focus visibility (#324) ─────────────────────
Baseline visible focus indicator for every interactive
element, so a keyboard user can always see where they are.
Components that define their own focus ring (Tailwind
`focus-visible:ring-*` in the utilities layer) override
this with their more specific styling. `:focus-visible`
(not `:focus`) means mouse/pointer clicks do NOT flash a
ring, only keyboard navigation. High-contrast brand-purple
ring with clear offset works on both light and dark themes.
──────────────────────────────────────────────────────── */
:focus-visible {
outline: 2px solid var(--focus-ring, rgb(var(--brand-purple) / 0.9));
outline-offset: 2px;
border-radius: 2px;
}
}

/* ── Reduced-motion support ─────────────────────────────────
Expand Down
10 changes: 7 additions & 3 deletions frontend/components/Pagination.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
export default function Pagination({ currentPage, totalPages, onPageChange }) {
return (
<div className="flex justify-center gap-4 mt-10">
<div className="flex justify-center items-center gap-4 mt-10">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50"
className="px-4 py-2 bg-gray-200 text-gray-900 rounded hover:bg-gray-300
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-purple
focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
Expand All @@ -14,7 +16,9 @@ export default function Pagination({ currentPage, totalPages, onPageChange }) {
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50"
className="px-4 py-2 bg-gray-200 text-gray-900 rounded hover:bg-gray-300
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-purple
focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
Expand Down
6 changes: 3 additions & 3 deletions frontend/components/PuzzleComponent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ const PuzzleComponent = ({
walletConnected ? "bg-emerald-400 shadow-sm shadow-emerald-400/50" : "bg-gray-500"
} transition-colors duration-300`}
/>
<span className="text-[10px] text-gray-500">
<span className="text-[10px] mt-0.5 text-gray-400">
{walletConnected
? `${walletAddress?.slice(0, 4)}…${walletAddress?.slice(-4)}`
: "Wallet disconnected"}
Expand Down Expand Up @@ -328,10 +328,10 @@ const PuzzleComponent = ({
value={answer}
onChange={handleAnswerChange}
disabled={submitting || isCorrect}
className="h-12 border-white/10 bg-white/[0.04] pr-12 text-white placeholder:text-gray-500 focus:border-purple-400/50 focus:ring-2 focus:ring-purple-400/20"
className="h-12 border-white/10 bg-white/[0.04] pr-12 text-white placeholder:text-gray-400 focus:border-purple-400/50 focus:ring-2 focus:ring-purple-400/30"
/>
{/* Character count indicator */}
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-600">
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-400">
{answer.length}
</span>
</div>
Expand Down
26 changes: 0 additions & 26 deletions frontend/components/TestComponent.jsx

This file was deleted.

14 changes: 8 additions & 6 deletions frontend/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ module.exports = {
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'var(--background)',
foreground: 'var(--foreground)'
},
theme: { extend: {
colors: {
background: 'var(--background)',
foreground: 'var(--foreground)',
'brand-purple': 'rgb(var(--brand-purple) / <alpha-value>)',
'brand-pink': 'rgb(var(--brand-pink) / <alpha-value>)',
'brand-dark': 'rgb(var(--brand-dark) / <alpha-value>)'
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
Expand Down
48 changes: 48 additions & 0 deletions frontend/tests/TestComponentRemoved.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';

// ─────────────────────────────────────────────────────────────────────────
// Guard against reintroducing TestComponent (issue #337)
//
// `frontend/components/TestComponent.jsx` used to fetch from
// `https://jsonplaceholder.typicode.com` inside a client component and was
// wired into the production tree. It was removed because it had no purpose
// in the shipped app and leaked an external test dependency into the client
// bundle. These assertions keep the external test request out of the
// non-test source tree so it cannot silently come back.
// ─────────────────────────────────────────────────────────────────────────

function walk(dir) {
if (!fs.existsSync(dir)) return [];
let results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
// Skip node_modules, build artifacts, and hidden dirs.
if (['node_modules', '.next', '.turbo'].includes(entry.name)) continue;
if (entry.name.startsWith('.')) continue;
// Guard tests themselves may reference these strings, so don't descend
// into tests/ (which would read this very file) or test.fixtures.
if (entry.name === 'tests') continue;
if (entry.isDirectory()) {
results = results.concat(walk(full));
} else if (/(\.(js|jsx|ts|tsx))$/.test(entry.name)) {
results.push(full);
}
}
return results;
}

describe('TestComponent removal guards', () => {
it('does not contain a jsonplaceholder reference in production source', () => {
const tainted = walk(path.join(__dirname, '..'))
.filter((f) => fs.readFileSync(f, 'utf8').includes('jsonplaceholder'))
.map((f) => path.relative(path.join(__dirname, '..'), f));
expect(tainted).toEqual([]);
});

it('does not re-add frontend/components/TestComponent.jsx', () => {
const target = path.join(__dirname, '..', 'components', 'TestComponent.jsx');
expect(fs.existsSync(target)).toBe(false);
});
});
Loading