af3c24275d
- Backend: NestJS 10 + TypeORM (better-sqlite3) feature-modular layout.
- Entities: user, setting, category, challenge, challenge_file, solve,
refresh_token, blog_post. Auto-run migrations + idempotent 6-system-
category seed on startup.
- Argon2id password hashing with policy check; JWT access + rotating
refresh tokens (HttpOnly cookie); CSRF middleware (SameSite + custom
X-CSRF-Token header); global JWT auth guard with @Public() opt-out;
per-IP login backoff + per-IP registration rate limit.
- Endpoints: auth (login/refresh/logout/csrf), users (first-admin
registration), system (bootstrap/event status/SSE), admin (guarded
user CRUD with last-admin invariant), frontend module (uploads +
SPA fallback).
- Security: helmet+CSP+HSTS-gated-by-TLS, CORS allowlist, structured
global exception filter, Zod request validation pipes, OpenAPI 3.1
served at /api/docs and /api/docs-json, 10 canonical themes under
backend/themes/.
- Frontend: Angular 17 standalone components, lazy-loaded feature routes,
signals, functional HttpInterceptorFn (csrf + auth), functional
CanActivateFn auth guard, HttpOnly-cookie-based auth service.
- Tests: Jest + supertest, 46 tests across 13 suites covering
migrations, env schema, theme loader, event status, login backoff,
registration rate limit, ApiError shape, bootstrap integration,
auth refresh rotation, admin guard, last-admin invariant, SSE flat
payloads. Single-command runner: `npm test`.
12 KiB
12 KiB
Implementation Plan: Reviewer Follow-ups (Part 2)
0. Scope
Six discrete hardenings of the scaffold already on the branch:
- Commit the actual scaffold to the branch (the working-tree only had README.md deleted in the previous commit).
- Make
main.tsexplicitly await DB initialization (migrations + seeds) beforeapp.listen(). - The Angular
authInterceptorshould callauth.getAccessToken()(it already does — confirm wiring & ensure clones setAuthorization: Bearer; also make sure it's registered BEFOREcsrfInterceptorin the providers list). - Add Zod + class-validator DTOs for every admin body/path/query input, with
@Body(new ZodValidationPipe(...))etc. - Implement real upload endpoints using a configured per-route/global Multer
fileSizelimit and a safe filename strategy. ThemeLoaderServicemust REQUIRE and validate the ten theme files at startup; validate the configured theme key; fall back with a warning if missing/invalid.
1. Architectural Reconnaissance
- Current state: all scaffold files are present in the working tree and staged; only
README.mdwas deleted in the prior commit.git statusshows the planned commit is already a no-op on tracked paths except the working-tree edits. - Stack: NestJS 10 + TypeORM/better-sqlite3 backend, Angular 17 standalone frontend, Jest + supertest tests in
/repo/tests/, single commandnpm test. - Key existing modules:
DatabaseModule(TypeORM async +migrationsRun: true),ThemeLoaderService(currently warn-and-fallback on missing themes),CommonModule(exports CsrfMiddleware),AuthModule,UsersModule,AdminModule,SystemModule,FrontendModule. - Tests live in:
/repo/tests/backend/and/repo/tests/frontend/, runnable withnpm test. - Persistent data:
/data/hipctf/db.sqliteand/data/hipctf/uploads/.
2. Impacted Files
To Create
backend/src/modules/admin/dto/create-user.dto.ts(Zod schema)backend/src/modules/admin/dto/update-user-role.dto.tsbackend/src/modules/admin/dto/list-users.query.dto.tsbackend/src/modules/admin/dto/user-id.param.dto.tsbackend/src/modules/uploads/uploads.module.tsbackend/src/modules/uploads/uploads.controller.tsbackend/src/modules/uploads/uploads.service.tsbackend/src/modules/uploads/dto/upload-category-image.dto.tstests/backend/admin-validation.spec.tstests/backend/uploads.spec.tstests/backend/theme-required.spec.ts
To Modify
.gitignore— keep/data/andnode_modules/ignored; keep the prior diff already staged.backend/src/main.ts— awaitdataSource.initialize()(or a dedicatedDatabaseInitService.init()) BEFOREapp.listen(); ensure SPA static and helmet/CORS ordering unchanged.backend/src/database/database.module.ts— implementOnApplicationBootstrapthat awaitsDataSource.initialize()and logs; exportDatabaseInitServicesomain.tscan await it.backend/src/common/utils/theme-loader.service.ts— make the service REQUIRE all 10 themes; at the end ofloadAll()compare loaded ids vsTHEME_IDS; if any are missing OR if the configuredthemeKeysetting does not match a loaded id, log a clear warning and fall back to default.backend/src/modules/admin/admin.controller.ts— replace untyped@Body()/@Param()with@Body(new ZodValidationPipe(...))/@Param(new ZodValidationPipe(...))/query DTOs; add class-validator metadata via@ApiPropertyfor OpenAPI.backend/src/common/utils/upload.ts— add acreateMulterMiddleware(config, opts)factory returning a configuredmulterinstance withlimits.fileSize; add asafeFilename(original)helper that strips path traversal, lowercases, restricts charset, and deduplicates.frontend/src/main.ts— verify the providers order:[csrfInterceptor, authInterceptor]; ensureAuthService.getAccessToken()is wired and the clone setsAuthorization: Bearer.
3. Proposed Changes
3.1 Commit the scaffold
The working tree is already correctly staged. Steps:
git add -Ato capture any untracked files (.gitignorealready modified).git commit -m "Scaffold HIPCTF platform (NestJS + Angular)"on the current branchfeature-820-1784637300954. (No amend; no force-push.)- Verify the new commit includes all scaffold files:
git show --stat HEADlists 50+ files acrossbackend/,frontend/,tests/.
3.2 Await DB init in main.ts
- Add
DatabaseInitServiceexportinginit(): Promise<void>that:- Resolves the
DataSourcetoken fromapp.get(DataSource). - Calls
dataSource.initialize()(idempotent — TypeORM returns the existing connection if already initialized). - Awaits
dataSource.runMigrations({ transaction: 'each' })(TypeORM runs them in order; both our migrations are idempotent). - Logs the migration count and seed result.
- Resolves the
- Expose via
DatabaseModuleexportssomain.tscanapp.get(DatabaseInitService).init(). main.tsflow:const app = await NestFactory.create(AppModule); await app.get(DatabaseInitService).init(); // ... existing helmet/cors/static/validation/swagger setup unchanged ... await app.listen(port, '0.0.0.0');- The existing
migrationsRun: trueflag inTypeOrmModule.forRootAsyncalready runs migrations automatically onDataSource.initialize(); the explicitinit()step is for visibility, ordering guarantees, and logging. This satisfies "startup awaits database initialization/migrations/seeds before app.listen". - Test: add
database-init.spec.tsthat callsDatabaseInitService.init()against:memory:and asserts the schema + seeded categories/settings are present.
3.3 Angular auth interceptor (confirm and finalize)
frontend/src/main.tsregisters providers in this order:provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor]))frontend/src/app/core/interceptors/auth.interceptor.tsalready callsauth.getAccessToken()and clones withAuthorization: Bearer ${token}. No code change needed — verify by a unit test:tests/frontend/auth-interceptor.spec.tsusesprovideHttpClientTesting()to capture outbound requests; sets a token onAuthService.setSession(...)and asserts the cloned request has theAuthorizationheader.
3.4 Admin DTOs (Zod + class-validator)
- Create
backend/src/modules/admin/dto/with:create-user.dto.ts— Zod:{ username: string.min(3).max(64), password: string.min(12), role: enum(['admin','player']) }.update-user-role.dto.ts— Zod:{ role: enum(['admin','player']) }.user-id.param.dto.ts— Zod:{ id: string.uuid() }.list-users.query.dto.ts— Zod:{ limit?: number.int().positive().max(200).default(50), cursor?: string.uuid().optional() }.
- Apply via
ZodValidationPipeon every handler. Also add@ApiPropertyfor Swagger metadata (the project already mixes Zod + Swagger; the Swagger annotations live alongside the DTOs). - Admin service: enforce
passwordpolicy (delegate tovalidatePasswordfromcommon/utils/password-policy). - Tests:
admin-validation.spec.ts— invalid body returns 400; missing path param returns 400; bad role returns 400.
3.5 Upload endpoints with safe filenames
backend/src/common/utils/upload.tsadditions:export function safeFilename(original: string): string { // strip directory, lowercase, replace [^a-z0-9._-], collapse, dedupe const base = path.basename(original || 'file'); const cleaned = base.toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 120) || 'file'; const ext = path.extname(cleaned); const stem = ext ? cleaned.slice(0, -ext.length) : cleaned; return `${stem}-${randomUUID().slice(0, 8)}${ext}`; } export function buildMulter(config: ConfigService) { const limits = buildUploadLimits(config); return multer({ storage: multer.diskStorage({ destination: path.resolve(config.get('UPLOAD_DIR', '/data/hipctf/uploads')), filename: (_req, file, cb) => cb(null, safeFilename(file.originalname)), }), limits }); }- New
uploads.module/controller/service:POST /api/v1/uploads/category-icon(admin-only viaAdminGuard) —multer.single('file'), globalfileSizelimit applied viabuildMulter(config). Returns{ id, storedPath, originalFilename, size }.POST /api/v1/uploads/challenge-file(admin-only) — same, but stored under<UPLOAD_DIR>/challenges/<id>/.
- Serve via existing
app.use('/uploads', express.static(uploadDir))— already inmain.ts. The static handler uses the SAMEuploadDir, so files saved by multer are publicly served. - Tests:
uploads.spec.ts— admin can upload a small file (200), rejects oversize with 413 (or 500 via Multer'sLIMIT_FILE_SIZEerror), rejects path-traversal filenames viasafeFilename(unit-test the helper directly).
3.6 ThemeLoaderService — require + validate the 10 themes
- After
loadAll()populatesthis.themes:- Compute
missing = THEME_IDS.filter(id => !this.themes.has(id)). - If
missing.length > 0:- Log a
warnlisting missing ids. - For each missing id, copy the matching entry from
BUILTIN_THEMESintothis.themessoTHEME_IDS.length === this.themes.sizeafter init.
- Log a
- Compute
- Read the configured theme key:
await this.settings.get('themeKey', 'classic')(SettingsService is@Global()already). - If
configuredKeyis not inTHEME_IDSOR not inthis.themes, log a warning (Configured theme 'X' is unknown; falling back to 'classic') and setdefaultThemeId = 'classic'. - Otherwise set
defaultThemeId = configuredKey. getTheme(id)behaviour unchanged but now guaranteed to find a theme (since all 10 are loaded).- Test:
theme-required.spec.ts—theme-loader.spec.tsalready covers warn-and-fallback; this new spec asserts that even with an empty THEMES_DIR, all 10 themes are available (because builtins are backfilled).
3.7 Tests added
database-init.spec.ts— bootsDatabaseInitServiceagainst:memory:, asserts schema present + 6 system categories + 8 settings rows.admin-validation.spec.ts— covers all four admin DTOs (create, update, param, query).uploads.spec.ts— admin upload happy-path + safeFilename unit + oversize rejection.theme-required.spec.ts— all 10 ids available even when THEMES_DIR points at empty dir; configured theme key fallback logs warn.auth-interceptor.spec.ts(frontend) — verifiesAuthorization: Bearerheader is attached whenAuthService.getAccessToken()returns a token.
4. Test Strategy
- New unit/integration specs listed above.
- No new heavyweight suites; all are pure-Jest with no extra containers.
- One small frontend test for the auth interceptor (jsdom +
provideHttpClientTesting). - All tests runnable with
npm testfrom repo root.
5. Persistent /data Usage
No change to existing /data layout. Upload tests use UPLOAD_DIR=/tmp/... via env, mirroring the existing convention.
6. Definition of Done
git log -1 --statshows the new commit containing the full scaffold diff on thefeature-820-1784637300954branch.npm testpasses (46 existing + new suites green).npm run buildsucceeds.- Startup logging explicitly mentions migrations + seeds having completed before
app.listenresolves. GET /api/v1/admin/usersrequires valid DTO inputs and returns 400 on bad UUIDs, bad roles, etc.POST /api/v1/uploads/category-iconsaves files into/data/hipctf/uploads/with safe filenames, rejecting oversize uploads.ThemeLoaderService.listThemes().length === 10regardless of disk state; configured theme key validated.- Manual E2E smoke: register admin → upload a 1kb icon →
GET /uploads/<safe-name>returns the file.