AI Implementation feature(899): Data and run #39

Merged
m0rph3us1987 merged 2 commits from feature-899-1784749302363 into dev 2026-07-22 19:56:33 +00:00
23 changed files with 305 additions and 53 deletions
+1
View File
@@ -6,6 +6,7 @@ dist/
!.env.example !.env.example
.DS_Store .DS_Store
coverage/ coverage/
data/
backend/data/ backend/data/
backend/themes/.cache/ backend/themes/.cache/
frontend/.angular/ frontend/.angular/
-35
View File
@@ -1,35 +0,0 @@
# Implementation Plan: Admin Area General Settings and Categories 1.16
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript monorepo with a NestJS 10 REST backend and Angular 17 standalone components. Backend controllers currently delegate category persistence to TypeORM-backed services and validate JSON DTOs with Zod pipes; uploads use Nest `FileInterceptor`, in-memory Multer buffers, and Sharp. Frontend admin pages use standalone OnPush components, signals, typed reactive forms, and an `AdminService` wrapper around typed `HttpClient` calls converted to promises with `firstValueFrom`.
- **Data Layer:** SQLite through TypeORM. Category abbreviations are stored uniquely, are normalized to uppercase by `AdminCategoriesService`, and list queries order by `LOWER(abbreviation)`. No schema migration is needed because `UpdateCategorySchema`, the entity, and service update path already support abbreviation changes for non-system categories.
- **Test Framework & Structure:** Jest 29 with `ts-jest`, split into root-level dedicated `tests/backend` and `tests/frontend` projects and runnable together with root `npm test`. Backend endpoint tests use Nest's testing module, Supertest, an in-memory SQLite database, and an isolated upload directory; frontend tests favor exported pure component helpers rather than browser or visual checks.
- **Required Tools & Dependencies:** No new system tools, global CLIs, or package dependencies are required. Sharp is already installed in both the backend and root test dependencies and can validate/decode and normalize uploaded images. Existing `setup.sh` already creates persistent `/data/hipctf/uploads`, installs dependencies, rebuilds the native SQLite binding, and builds both workspaces, so no setup change is required.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/admin/categories/categories.component.ts` — include the submitted abbreviation in user-category update requests, preserve the modal on failure, and route save/upload errors to form-modal state rather than delete-modal state.
- `frontend/src/app/features/admin/categories/category-form-modal.component.ts` — accept and render a clear save/upload error and accurately expose submission state while the parent performs the async operation.
- `backend/src/modules/uploads/uploads.controller.ts` — reject undecodable/non-image category-icon buffers before any filesystem write instead of falling back to raw bytes.
- `tests/frontend/admin-categories-form-modal.spec.ts` — add minimal logic assertions for user-category abbreviation editability/submission and form error-state contract as appropriate to the extracted helpers.
- `tests/backend/uploads.spec.ts` — replace the legacy text-file success expectation with a real valid image success case and add focused corrupt/non-image rejection checks that assert no overwrite or new file occurs.
- **To Create:** None.
## 3. Proposed Changes
1. **Database / Schema Migration:** No database or migration changes. Retain the existing `UpdateCategorySchema.abbreviation` field and `AdminCategoriesService.update()` behavior, which uppercases non-system abbreviations, rejects duplicates, and protects system abbreviations. After a successful client save, the existing reload and defensive sort will move the renamed row into alphabetical order.
2. **Backend Logic & APIs:**
- In the existing `POST /api/v1/uploads/category-icon` flow, treat Sharp decode/resize failure as a `BadRequestException` with a clear image-validation message; do not use multipart MIME type, filename extension, or browser `accept` metadata as proof of validity.
- Decode and normalize the in-memory buffer completely to a 128x128 PNG before creating directories or calling `writeFileSync`. Only after Sharp succeeds should the deterministic `{categoryId}.png` path be overwritten. This preserves any previously valid icon when a replacement is corrupt and prevents partial/new files for invalid uploads.
- Return the existing successful response contract (`publicUrl`, `width: 128`, `height: 128`, `mimeType: image/png`, plus current metadata fields) so `AdminService.uploadCategoryIcon()` remains compatible. Keep the existing size and missing-file checks unchanged.
- Keep category persistence separate from upload persistence: on edit, the frontend must not dispatch the category update after upload rejection, so `updatedAt` and other fields remain unchanged. On create, retain the current create-then-upload architecture; if upload fails, no image or icon-path update is created, although the category row already created by the existing API remains. Do not introduce a database migration or new multipart category-creation endpoint outside this job's reported edit/upload defects.
3. **Frontend UI Integration:**
- Extend the edit branch of `AdminCategoriesComponent.onFormSubmit()` to pass `abbreviation: payload.abbreviation` alongside name, description, and icon path. The modal already uppercases the emitted value, while the server remains the canonical normalizer and protects system rows.
- Preserve the current `syncCategoryForm()` contract: user rows remain editable (`readonly=false`) and system rows remain locked (`readonly=true`). Do not mark user-category abbreviation readonly, because the required behavior is to persist it rather than silently discard it.
- Add dedicated category-form error state in the container, clear it when opening/closing or beginning a submission, and bind it into the form modal. On upload or update failure, keep the edit modal open, show the server's clear error, and leave the existing list/icon unchanged; do not reuse `deleteError`, which is only rendered by the delete modal.
- Coordinate a parent-managed saving flag with the modal so repeated submissions are disabled during upload/update and reset in `finally`. The modal should render the bound error using a stable test id and continue to emit a typed `CategoryFormSubmit`; no HTTP calls move into the presentational component.
## 4. Test Strategy
- **Target Unit Test File:**
- `tests/backend/uploads.spec.ts`: use Sharp to generate one tiny valid image and verify category-icon normalization succeeds; upload a plain-text payload and a corrupt PNG using a deterministic `categoryId`, expect HTTP 400 with the validation message, and assert the target icon is absent or that pre-existing valid bytes are unchanged.
- `tests/frontend/admin-categories-form-modal.spec.ts`: retain pure, CLI-only tests and add only the core contract checks needed to prove a non-system edit stays writable and produces/forwards an uppercase abbreviation, plus that a supplied save error is exposed without closing/resetting the form. If submission mapping is not currently isolatable, extract a small typed pure helper from the modal/container rather than building a large TestBed environment.
- **Mocking Strategy:** Backend endpoint tests use the real Nest route, Sharp, and filesystem boundary in the suite's isolated temporary upload directory, with generated buffers and explicit cleanup; they do not mock image decoding or require `/data`. Frontend tests mock no browser UI and exercise exported pure helpers/typed payload mapping directly. Run all tests from `/repo` with `npm test`; then run the existing workspace builds (`npm --workspace frontend run build` and `npm --workspace backend run build`) as type/build verification. No visual confirmation or external services are required.
+43
View File
@@ -0,0 +1,43 @@
# Implementation Plan: Data and run
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** npm-workspace TypeScript monorepo with a NestJS 10 backend and Angular 17 standalone frontend. Runtime configuration is validated centrally with Zod in `backend/src/config/env.schema.ts` and consumed through Nest `ConfigService`; filesystem paths are normalized with Node `path.resolve`, and root scripts delegate to workspace scripts. The feature is not already implemented: persistent defaults still point at `/data/hipctf`, and the root package has no `dev` script.
- **Data Layer:** TypeORM with `better-sqlite3`. `DatabaseModule` creates the database parent directory before connecting, `DatabaseInitService` runs migrations and seeds, and uploads are written and served from the configured upload directory. No schema migration is required; this job changes only default filesystem locations and development process orchestration.
- **Test Framework & Structure:** Jest 29 with `ts-jest`, configured in `tests/jest.config.js` as backend and frontend projects. Tests live under dedicated `tests/backend` and `tests/frontend` folders and all run from the repository root with `npm test`. Add only focused configuration/script contract tests; no browser, UI, database file, port, or long-running process is required.
- **Required Tools & Dependencies:** Existing Node.js/npm workspace tooling, Angular CLI, `ts-node`, TypeScript, Jest, and `better-sqlite3` remain required. Add the root development dependency `concurrently` (and update `package-lock.json`) so one root command can supervise both long-running workspaces and propagate shutdown/failure. `setup.sh` already installs root/workspace dependencies via `npm ci`; replace its obsolete absolute `/data/hipctf/uploads` preparation with project-local `./data/uploads` preparation. No global CLI or new system package is required.
## 2. Impacted Files
- **To Modify:**
- `backend/src/config/env.schema.ts` — change validated `DATABASE_PATH` and `UPLOAD_DIR` defaults to `./data/db.sqlite` and `./data/uploads`.
- `backend/src/database/database.module.ts` — align the `ConfigService.get` database fallback with the canonical project-local database default.
- `backend/src/database/database-init.service.ts` — align `getDbPath()` fallback with the canonical project-local database default.
- `backend/src/main.ts` — align startup upload-directory fallback with the canonical project-local upload default.
- `backend/src/modules/uploads/uploads.controller.ts` — align upload controller fallback with the project-local upload default.
- `backend/src/common/utils/upload.ts` — align Multer destination fallback with the project-local upload default.
- `backend/src/frontend/uploads-static.middleware.ts` — align static-upload middleware fallback with the project-local upload default.
- `package.json` — add root `dev` orchestration for backend and frontend and retain `start:dev` as a compatible alias if desired.
- `package-lock.json` — lock the new process-runner dependency and root package metadata.
- `setup.sh` — create `./data/uploads` relative to the repository root instead of `/data/hipctf/uploads`, while retaining dependency installation and builds.
- `.gitignore` — ignore the root runtime `data/` directory rather than the unrelated `backend/data/` path, preventing local database/uploads from entering source control.
- `tests/backend/env-schema.spec.ts` — assert the exact project-local data defaults returned by the Zod schema.
- **To Create:**
- `tests/backend/root-dev-script.spec.ts` — focused static contract test for root development orchestration and both workspace targets.
## 3. Proposed Changes
1. **Database / Schema Migration:**
- Do not add a TypeORM migration or alter tables. Update the canonical default database location from `/data/hipctf/db.sqlite` to `./data/db.sqlite`.
- Replace every hard-coded database fallback outside the schema with the same value so direct/unit construction cannot silently revert to `/data` when configuration is absent.
- Preserve environment overrides such as `DATABASE_PATH=:memory:` in tests and custom deployment paths; only defaults change.
2. **Backend Logic & APIs:**
- Change the canonical default upload location from `/data/hipctf/uploads` to `./data/uploads` in the environment schema and every defensive `ConfigService.get` fallback used by startup, upload handling, Multer storage, and static serving.
- Continue resolving relative paths through `path.resolve`; when launched through the root scripts, `./data` resolves from the project root. Preserve recursive directory creation in `DatabaseModule`, `main.ts`, upload helpers, and middleware.
- Update `setup.sh` to derive/use the repository-local data directory and create `data/uploads`, avoiding writes to the containers shared `/data` volume for normal application defaults. Keep setup idempotent.
- Update `.gitignore` for `/data/` so generated SQLite and uploaded files remain local runtime artifacts.
- Add `concurrently` as a root development dependency and define `npm run dev` to launch `npm --workspace backend run start:dev` and `npm --workspace frontend run start` in parallel, with clear process names and kill-on-peer-failure/shutdown behavior. Point the existing root `start:dev` script at `npm run dev` to avoid two conflicting development entry points.
3. **Frontend UI Integration:**
- No Angular source or API URL changes are needed. The Angular dev server remains on port 4200 while the Nest API remains on port 3000; both become available from one root command.
- Because the frontend uses relative `/api` and `/uploads` URLs and no Angular dev-server proxy is currently configured, add a development proxy configuration (and reference it from Angulars `serve` target) routing `/api` and `/uploads` to `http://localhost:3000`. This makes the concurrently started frontend functional rather than merely starting two disconnected processes, including credentialed API calls and SSE streams.
## 4. Test Strategy
- **Target Unit Test File:** Extend `tests/backend/env-schema.spec.ts` with one success-path assertion that empty input resolves to `DATABASE_PATH === './data/db.sqlite'` and `UPLOAD_DIR === './data/uploads'`. Add `tests/backend/root-dev-script.spec.ts` to read root `package.json` and `frontend/angular.json`, asserting that `dev` invokes both workspace development commands and the Angular development serve target references the backend proxy. Both tests execute through the existing single root `npm test` command.
- **Mocking Strategy:** No external boundaries need heavy mocks. Parse checked-in JSON directly with Node filesystem APIs; do not spawn `npm run dev`, bind ports, launch Angular/Nest, touch `/data`, or create persistent files. The environment-schema test calls the pure Zod parser. During implementation, follow Red-Green-Refactor, then verify with `npm test`, backend/frontend builds, and the repositorys available lint/typecheck commands (no lint script currently exists; the builds provide TypeScript compilation checks).
+1 -1
View File
@@ -63,7 +63,7 @@ export function buildMulter(config: ConfigService, opts: MulterOptions = {}): mu
const limits = opts.fileSize !== undefined const limits = opts.fileSize !== undefined
? { fileSize: opts.fileSize } ? { fileSize: opts.fileSize }
: buildUploadLimits(config); : buildUploadLimits(config);
const dest = opts.destination ?? path.resolve(config.get<string>('UPLOAD_DIR', '/data/hipctf/uploads')); const dest = opts.destination ?? path.resolve(config.get<string>('UPLOAD_DIR', './data/uploads'));
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
return multer({ return multer({
storage: multer.diskStorage({ storage: multer.diskStorage({
+2 -2
View File
@@ -4,8 +4,8 @@ export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000), PORT: z.coerce.number().int().positive().default(3000),
DATABASE_PATH: z.string().min(1).default('/data/hipctf/db.sqlite'), DATABASE_PATH: z.string().min(1).default('./data/db.sqlite'),
UPLOAD_DIR: z.string().min(1).default('/data/hipctf/uploads'), UPLOAD_DIR: z.string().min(1).default('./data/uploads'),
THEMES_DIR: z.string().min(1).default('./themes'), THEMES_DIR: z.string().min(1).default('./themes'),
FRONTEND_DIST: z.string().min(1).default('../frontend/dist'), FRONTEND_DIST: z.string().min(1).default('../frontend/dist'),
@@ -113,6 +113,6 @@ export class DatabaseInitService implements OnApplicationBootstrap {
} }
getDbPath(): string { getDbPath(): string {
return this.config.get<string>('DATABASE_PATH', '/data/hipctf/db.sqlite'); return this.config.get<string>('DATABASE_PATH', './data/db.sqlite');
} }
} }
+1 -1
View File
@@ -44,7 +44,7 @@ const MIGRATIONS = [
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => { useFactory: (config: ConfigService) => {
const dbPath = config.get<string>('DATABASE_PATH', '/data/hipctf/db.sqlite'); const dbPath = config.get<string>('DATABASE_PATH', './data/db.sqlite');
const isMemory = dbPath === ':memory:' || dbPath.startsWith('file:'); const isMemory = dbPath === ':memory:' || dbPath.startsWith('file:');
if (!isMemory) { if (!isMemory) {
const dir = path.dirname(dbPath); const dir = path.dirname(dbPath);
@@ -9,7 +9,7 @@ export class UploadsStaticMiddleware implements NestMiddleware {
private rootDir = '/tmp'; private rootDir = '/tmp';
constructor(config: ConfigService) { constructor(config: ConfigService) {
const dir = path.resolve(config.get<string>('UPLOAD_DIR', '/data/hipctf/uploads')); const dir = path.resolve(config.get<string>('UPLOAD_DIR', './data/uploads'));
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
this.rootDir = dir; this.rootDir = dir;
} }
+1 -1
View File
@@ -28,7 +28,7 @@ async function bootstrap(): Promise<void> {
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const bodyLimit = config.get<string>('BODY_SIZE_LIMIT', '1mb'); const bodyLimit = config.get<string>('BODY_SIZE_LIMIT', '1mb');
const uploadDir = path.resolve(config.get<string>('UPLOAD_DIR', '/data/hipctf/uploads')); const uploadDir = path.resolve(config.get<string>('UPLOAD_DIR', './data/uploads'));
const tlsEnabled = config.get<boolean>('TLS_ENABLED', false); const tlsEnabled = config.get<boolean>('TLS_ENABLED', false);
const frontendDist = path.resolve(config.get<string>('FRONTEND_DIST', '../frontend/dist')); const frontendDist = path.resolve(config.get<string>('FRONTEND_DIST', '../frontend/dist'));
@@ -19,7 +19,7 @@ export class UploadsController {
private readonly globalLimit: number; private readonly globalLimit: number;
constructor(private readonly config: ConfigService) { constructor(private readonly config: ConfigService) {
this.uploadDir = path.resolve(this.config.get<string>('UPLOAD_DIR', '/data/hipctf/uploads')); this.uploadDir = path.resolve(this.config.get<string>('UPLOAD_DIR', './data/uploads'));
this.globalLimit = parseUploadSizeLimit(this.config.get<string>('UPLOAD_SIZE_LIMIT', '50mb')); this.globalLimit = parseUploadSizeLimit(this.config.get<string>('UPLOAD_SIZE_LIMIT', '50mb'));
} }
+5
View File
@@ -12,6 +12,7 @@ timestamp: 2026-07-22T19:18:00Z
|---|---| |---|---|
| `backend/src/main.ts` | Bootstraps Nest, middleware, OpenAPI, static assets, and SPA fallback. | | `backend/src/main.ts` | Bootstraps Nest, middleware, OpenAPI, static assets, and SPA fallback. |
| `backend/src/app.module.ts` | Wires feature modules and global providers. | | `backend/src/app.module.ts` | Wires feature modules and global providers. |
| `backend/src/config/env.schema.ts` | Zod-validated runtime config; canonical defaults for `DATABASE_PATH` (`./data/db.sqlite`) and `UPLOAD_DIR` (`./data/uploads`). |
| `backend/src/database/database.module.ts` | Configures TypeORM with SQLite. | | `backend/src/database/database.module.ts` | Configures TypeORM with SQLite. |
| `backend/src/database/database-init.service.ts` | Initializes the database and runs migrations; `verifySeed()` also asserts the canonical six system-category keys and reports missing/duplicate rows. | | `backend/src/database/database-init.service.ts` | Initializes the database and runs migrations; `verifySeed()` also asserts the canonical six system-category keys and reports missing/duplicate rows. |
| `backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories.ts` | Forward-only repair migration: adds `created_at`/`updated_at` to legacy six-column `category` tables, deletes obsolete `FOR`/`OSI` system rows, rewrites canonical row metadata, inserts any missing canonical key, and re-asserts unique indexes. Exports `CANONICAL_SYSTEM_CATEGORIES`. | | `backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories.ts` | Forward-only repair migration: adds `created_at`/`updated_at` to legacy six-column `category` tables, deletes obsolete `FOR`/`OSI` system rows, rewrites canonical row metadata, inserts any missing canonical key, and re-asserts unique indexes. Exports `CANONICAL_SYSTEM_CATEGORIES`. |
@@ -35,6 +36,10 @@ timestamp: 2026-07-22T19:18:00Z
| File | Responsibility | | File | Responsibility |
|---|---| |---|---|
| `frontend/src/main.ts` | Bootstraps Angular and registers HTTP interceptors. | | `frontend/src/main.ts` | Bootstraps Angular and registers HTTP interceptors. |
| `frontend/proxy.conf.json` | Angular dev-server proxy: forwards `/api` and `/uploads` to `http://localhost:3000`. |
| `frontend/angular.json` | Angular workspace config; the `serve` target references `proxy.conf.json`. |
| `package.json` (root) | Root npm scripts; `dev` runs both workspaces via `concurrently`, `dev:backend` / `dev:frontend` run one at a time. |
| `setup.sh` | Idempotent setup: installs root + workspace deps, builds both packages, creates `./data/uploads`. |
| `frontend/src/app/app.routes.ts` | Defines public, shell, child, and admin routes. | | `frontend/src/app/app.routes.ts` | Defines public, shell, child, and admin routes. |
| `frontend/src/app/core/services/bootstrap.service.ts` | Caches and exposes bootstrap state, refreshes it after admin updates, and applies the resolved theme. | | `frontend/src/app/core/services/bootstrap.service.ts` | Caches and exposes bootstrap state, refreshes it after admin updates, and applies the resolved theme. |
| `frontend/src/app/core/services/bootstrap.types.ts` | Defines bootstrap/theme payload types and maps theme tokens to CSS custom properties. | | `frontend/src/app/core/services/bootstrap.types.ts` | Defines bootstrap/theme payload types and maps theme tokens to CSS custom properties. |
+2 -2
View File
@@ -28,11 +28,11 @@ Browser ──HTTPS──▶ NestJS process (PORT, default 3000)
│ │ │ │
│ ▼ │ ▼
│ better-sqlite3 │ better-sqlite3
│ (DATABASE_PATH) │ (DATABASE_PATH, default ./data/db.sqlite)
├── /api/docs, /api/docs-json ── Swagger UI (OpenAPI 3.1) ├── /api/docs, /api/docs-json ── Swagger UI (OpenAPI 3.1)
├── /uploads/* ── static file server (UPLOAD_DIR) ├── /uploads/* ── static file server (UPLOAD_DIR, default ./data/uploads)
└── /* ── SPA fallback (FRONTEND_DIST/index.html) └── /* ── SPA fallback (FRONTEND_DIST/index.html)
``` ```
+2 -1
View File
@@ -8,7 +8,8 @@ timestamp: 2026-07-22T16:44:54Z
# Purpose # Purpose
Some persistent `/data/hipctf/db.sqlite` databases were created before Some persistent HIPCTF databases (default path
[`./data/db.sqlite`](/database/schema.md)) were created before
the canonical six system categories existed. They contain a legacy the canonical six system categories existed. They contain a legacy
six-column `category` table (no `created_at` / `updated_at`) and six-column `category` table (no `created_at` / `updated_at`) and
system rows whose `system_key` / `abbreviation` no longer match the system rows whose `system_key` / `abbreviation` no longer match the
+3 -2
View File
@@ -10,8 +10,9 @@ timestamp: 2026-07-22T16:44:54Z
HIPCTF uses **SQLite via better-sqlite3** with TypeORM migrations. The HIPCTF uses **SQLite via better-sqlite3** with TypeORM migrations. The
database is a single file (`DATABASE_PATH`, default database is a single file (`DATABASE_PATH`, default
`/data/hipctf/db.sqlite`) running in **WAL journal mode** for better `./data/db.sqlite`, resolved relative to the process working directory)
concurrent read/write performance. running in **WAL journal mode** for better concurrent read/write
performance.
The schema is created by a single migration The schema is created by a single migration
(`backend/src/database/migrations/1700000000000-InitSchema.ts`) and seeded (`backend/src/database/migrations/1700000000000-InitSchema.ts`) and seeded
+119
View File
@@ -0,0 +1,119 @@
---
type: guide
title: Local Development Workflow
description: How to run the backend and frontend together in development, including the root dev script, Angular dev-server proxy, and project-local data directory.
tags: [guide, dev, dev-server, proxy, concurrently, setup, tester]
timestamp: 2026-07-22T19:54:00Z
---
# Overview
The HIPCTF monorepo ships a single root command — `npm run dev` — that
boots the NestJS backend and the Angular dev server in parallel, with
the Angular dev server proxying `/api` and `/uploads` calls to the
backend on port 3000. All persistent runtime files (SQLite database,
uploaded icons / attachments / logos) live under the project-local
`./data/` directory.
# How to start the full stack (tester steps)
1. From the repository root, install workspace dependencies once:
```
npm ci
```
2. Start both servers in parallel:
```
npm run dev
```
This uses `concurrently` to run:
* `npm --workspace backend run start:dev` (NestJS watch mode on `http://localhost:3000`)
* `npm --workspace frontend run start` (Angular dev server on `http://localhost:4200`)
Process names `backend` and `frontend` are prefixed per output line.
If either process exits with a non-zero status, the other is killed
(`--kill-others-on-fail`).
3. Open the browser:
* **Frontend (preferred for app work):** `http://localhost:4200/`
* **Backend (single-port mode, serves built SPA later):** `http://localhost:3000/`
4. To run them individually during debugging:
```
npm run dev:backend
npm run dev:frontend
```
# Project-local data directory
| Path | Purpose | Backed by env var |
|-------------------|-------------------------------------------------------|---------------------|
| `./data/` | Root runtime data directory (gitignored). | — |
| `./data/db.sqlite`| SQLite database file (created on first boot). | `DATABASE_PATH` |
| `./data/uploads/` | Uploaded category icons, challenge files, site logos. | `UPLOAD_DIR` |
Both defaults are declared in `backend/src/config/env.schema.ts` and
re-declared as fallbacks in every backend code path that calls
`ConfigService.get` so direct/unit construction cannot silently revert
to the legacy absolute paths. Relative paths are resolved with
`path.resolve` against the process working directory — when launched
via the root scripts, that is the repository root.
The legacy absolute defaults (`/data/hipctf/db.sqlite`,
`/data/hipctf/uploads`) have been removed; set `DATABASE_PATH` and
`UPLOAD_DIR` explicitly if you need to point at an external location
(e.g. a mounted volume in production).
The data directory is created automatically:
* `DatabaseModule` ensures the parent directory of `DATABASE_PATH`
exists before connecting.
* `main.ts`, `UploadsController`, `buildMulter`, and
`UploadsStaticMiddleware` all `mkdir -p` the upload directory on
startup.
* `setup.sh` creates `./data/uploads` before any build so first-boot
Multer writes never fail.
# Angular dev-server proxy
`frontend/proxy.conf.json` is wired into the `serve` target in
`frontend/angular.json` so relative URLs from the SPA hit the backend
without CORS prompts:
| Origin path | Target | Notes |
|-------------|-------------------------|--------------------------------------------------------|
| `/api` | `http://localhost:3000` | All REST controllers + SSE endpoints. `changeOrigin: true`. |
| `/uploads` | `http://localhost:3000` | Static files served by `UploadsStaticMiddleware`. |
The proxy is **development-only.** In production (`npm start` /
`backend/dist/main.js`) the backend serves the built SPA directly and
no proxy is involved.
# Root scripts
| Script | What it does |
|---------------------|--------------------------------------------------------------------|
| `npm run dev` | Start backend + frontend together via `concurrently`. |
| `npm run dev:backend` | Start only the NestJS backend in watch mode. |
| `npm run dev:frontend` | Start only the Angular dev server. |
| `npm start` | Run the **production** backend (`node backend/dist/main.js`). |
| `npm run build` | Build both workspaces (frontend first, then backend). |
| `npm run start:dev` | Alias for `npm run dev` (kept for backwards compatibility). |
| `npm test` | Run the full Jest project (backend + frontend). |
# Expected behavior (tester)
* `npm run dev` displays interleaved backend and frontend logs, each
prefixed with the process name and a color.
* The Angular dev server prints `Application bundle generation complete`
within a few seconds and the page is reachable at
`http://localhost:4200/`.
* Visiting `http://localhost:4200/` triggers
`GET /api/v1/bootstrap` (proxied to port 3000) and shows the
appropriate public, login, or bootstrap page.
* Uploads (e.g. category icons via the admin UI) land under
`./data/uploads/` and are immediately readable from the dev frontend
via `/uploads/...` (proxied).
* Pressing `Ctrl+C` once terminates both processes cleanly.
# See also
- [System Overview](/architecture/overview.md)
- [Database Schema Overview](/database/schema.md)
- [Uploads Endpoints](/api/uploads.md)
- [Key Files Index](/architecture/key-files.md)
+4 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-22T19:18:00Z. they need. Last regenerated 2026-07-22T19:54:00Z.
# Architecture # Architecture
@@ -80,6 +80,9 @@ they need. Last regenerated 2026-07-22T19:18:00Z.
* [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) - * [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) -
How a logout in one tab, or an unauthorized SSE response, instantly clears How a logout in one tab, or an unauthorized SSE response, instantly clears
and redirects every other open tab of the SPA. and redirects every other open tab of the SPA.
* [Local Development Workflow](/guides/dev-workflow.md) - How to run the
backend and frontend together (`npm run dev`), the Angular dev-server
proxy, and the project-local `./data/` directory.
* [Session Restoration on Reload](/guides/session-restoration.md) - How * [Session Restoration on Reload](/guides/session-restoration.md) - How
the SPA keeps a user signed in across hard refreshes and how the the SPA keeps a user signed in across hard refreshes and how the
guards wait for bootstrap + auth hydration before deciding where to guards wait for bootstrap + auth hydration before deciding where to
+3
View File
@@ -39,6 +39,9 @@
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": { "configurations": {
"production": { "buildTarget": "hipctf:build:production" }, "production": { "buildTarget": "hipctf:build:production" },
"development": { "buildTarget": "hipctf:build:development" } "development": { "buildTarget": "hipctf:build:development" }
+14
View File
@@ -0,0 +1,14 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true,
"ws": false
},
"/uploads": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true,
"ws": false
}
}
+55
View File
@@ -15,6 +15,7 @@
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/jsdom": "^28.0.3", "@types/jsdom": "^28.0.3",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"concurrently": "^9.2.4",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
@@ -7899,6 +7900,60 @@
"typedarray": "^0.0.6" "typedarray": "^0.0.6"
} }
}, },
"node_modules/concurrently": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.9.0",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/concurrently/node_modules/shell-quote": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concurrently/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/connect-history-api-fallback": { "node_modules/connect-history-api-fallback": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
+4
View File
@@ -11,6 +11,9 @@
"build": "npm --workspace frontend run build && npm --workspace backend run build", "build": "npm --workspace frontend run build && npm --workspace backend run build",
"start": "node backend/dist/main.js", "start": "node backend/dist/main.js",
"start:dev": "npm --workspace backend run start:dev", "start:dev": "npm --workspace backend run start:dev",
"dev": "concurrently --kill-others-on-fail --names \"backend,frontend\" --prefix-colors \"auto\" \"npm --workspace backend run start:dev\" \"npm --workspace frontend run start\"",
"dev:backend": "npm --workspace backend run start:dev",
"dev:frontend": "npm --workspace frontend run start",
"test": "jest --config tests/jest.config.js", "test": "jest --config tests/jest.config.js",
"test:backend": "jest --config tests/jest.config.js --selectProjects backend", "test:backend": "jest --config tests/jest.config.js --selectProjects backend",
"test:frontend": "jest --config tests/jest.config.js --selectProjects frontend", "test:frontend": "jest --config tests/jest.config.js --selectProjects frontend",
@@ -21,6 +24,7 @@
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/jsdom": "^28.0.3", "@types/jsdom": "^28.0.3",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"concurrently": "^9.1.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
+3 -3
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
echo "[setup] Ensuring /data/hipctf exists..." echo "[setup] Ensuring ./data/uploads exists..."
mkdir -p /data/hipctf/uploads mkdir -p ./data/uploads
echo "[setup] Installing root dependencies..." echo "[setup] Installing root dependencies..."
npm ci --no-audit --no-fund || npm install --no-audit --no-fund npm ci --no-audit --no-fund || npm install --no-audit --no-fund
@@ -16,4 +16,4 @@ npm --workspace frontend run build
echo "[setup] Building NestJS backend..." echo "[setup] Building NestJS backend..."
npm --workspace backend run build npm --workspace backend run build
echo "[setup] Done. Start with: npm start" echo "[setup] Done. Start with: npm start (dev mode: npm run dev)"
+12
View File
@@ -6,6 +6,18 @@ describe('envSchema', () => {
expect(r.success).toBe(true); expect(r.success).toBe(true);
}); });
it('defaults DATABASE_PATH to ./data/db.sqlite', () => {
const r = envSchema.safeParse({});
expect(r.success).toBe(true);
expect((r.data as any).DATABASE_PATH).toBe('./data/db.sqlite');
});
it('defaults UPLOAD_DIR to ./data/uploads', () => {
const r = envSchema.safeParse({});
expect(r.success).toBe(true);
expect((r.data as any).UPLOAD_DIR).toBe('./data/uploads');
});
it('rejects invalid PORT', () => { it('rejects invalid PORT', () => {
const r = envSchema.safeParse({ PORT: 'not-a-number' }); const r = envSchema.safeParse({ PORT: 'not-a-number' });
expect(r.success).toBe(false); expect(r.success).toBe(false);
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
import * as path from 'node:path';
describe('Root dev script orchestration', () => {
it('exposes a `dev` script that runs both backend and frontend workspaces', () => {
const pkg = JSON.parse(
readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'),
);
expect(typeof pkg.scripts?.dev).toBe('string');
expect(pkg.scripts.dev).toMatch(/workspace\s+backend/);
expect(pkg.scripts.dev).toMatch(/workspace\s+frontend/);
});
it('configures the Angular dev server to proxy /api and /uploads to the Nest backend', () => {
const angularJson = JSON.parse(
readFileSync(
path.join(__dirname, '..', '..', 'frontend', 'angular.json'),
'utf8',
),
);
const serve = angularJson.projects?.hipctf?.architect?.serve;
expect(serve).toBeDefined();
expect(typeof serve.options?.proxyConfig).toBe('string');
expect(serve.options.proxyConfig).toMatch(/proxy/);
});
});