--- type: architecture title: System Category Icon Seed description: Idempotent startup hook that writes deterministic 128×128 PNG icons for the canonical six system categories (CRY/HW/MSC/PWN/REV/WEB) into UPLOAD_DIR/icons, so freshly bootstrapped instances never serve 404 icons. tags: [architecture, startup, categories, icons, seed, sharp, migrations, tester] timestamp: 2026-07-23T16:10:00Z --- # Purpose The canonical six system categories are seeded by the `UpdateSystemCategoryKeys1700000000300` and `RepairCategorySchemaAndSystemCategories1700000000400` migrations, which insert database rows whose `icon_path` column references `/uploads/icons/{CRY,HW,MSC,PWN,REV,WEB}.png`. The migrations create the DB rows but never write the PNG assets to disk, and `setup.sh` only `mkdir -p`s the upload directory — it does not populate it. As a result, `GET /uploads/icons/CRY.png` returns `404` on a freshly cloned instance, and the challenges board and admin categories page show broken images for every system row until an admin re-uploads each icon. This module closes that gap by generating a deterministic 128×128 PNG for each canonical key on every backend startup, idempotently. Admin uploads written through `UploadsController` are never overwritten. # Files | File | Responsibility | |---|---| | `backend/src/database/system-category-icons.ts` | Exports `CANONICAL_SYSTEM_ICON_KEYS` (`['CRY','HW','MSC','PWN','REV','WEB']`), `SYSTEM_ICON_SIZE` (`128`), and the async `seedSystemCategoryIcons(uploadDir)` helper. Creates `/icons/` recursively, generates a sharp 128×128 PNG (palette-coloured background + white 2–6-letter abbreviation overlay) for every key whose `.png` does not yet exist or is zero-bytes, and returns `{ written, skipped }`. | | `backend/src/database/database-init.service.ts` | Calls `seedSystemCategoryIcons(path.resolve(config.get('UPLOAD_DIR', './data/uploads')))` from `init()` after migrations and `verifySeed()`. Skips when `UPLOAD_DIR` is unset or contains `:memory:`. Failures log a `WARN` and never abort startup. | | `tests/backend/system-category-icons.spec.ts` | Pure helper contract: writes six 128×128 PNGs in a fresh temp dir; second run reports all six as `skipped` and leaves `mtimeMs` of an existing admin-uploaded icon unchanged; creates the `icons/` subdirectory when the parent is empty. | # How the helper works `seedSystemCategoryIcons(uploadDir)`: 1. Resolves `iconsDir = path.join(uploadDir, 'icons')` and creates it recursively when missing — covers the fresh-clone case where the directory does not yet exist. 2. For each `key` in `CANONICAL_SYSTEM_ICON_KEYS`: * `target = path.join(iconsDir, `${key}.png`)`. * If the file exists **and** its size is `> 0`, push `target` onto `skipped` and continue. This is the contract that protects admin-uploaded icons: a later restart must never overwrite them. * Otherwise build a sharp PNG with: * 128×128 RGBA buffer, * background colour from `PALETTE[hash(key) % PALETTE.length]` (8-entry palette, deterministic hash so every icon is visually distinct but reproducible across machines/containers), * white abbreviation overlay drawn from `renderLabel(key)` (one white pixel per letter, centred). * Write the buffer with `fs.writeFileSync(target, buf)` and push `target` onto `written`. 3. Return `{ written, skipped }`. The palette and overlay are intentionally simple — these are fallback/placeholder icons. An admin who wants branded artwork uploads a real image through `POST /api/v1/uploads/category-icon`, which writes to the same `/icons/.png` path and is then preserved on subsequent restarts. # Startup wiring Inside `DatabaseInitService.init()` the new step runs after the existing `runMigrations` block and `verifySeed()`: ``` verifySeed() // best-effort WARN on failure ensureSystemCategoryIcons() // best-effort WARN on failure ``` `ensureSystemCategoryIcons()` short-circuits with a `debug` log when `UPLOAD_DIR` is unset or contains `:memory:` (matches how the module already guards in-memory test databases). Otherwise it logs: ``` System category icons: written= skipped= ``` A failure logs `ensureSystemCategoryIcons skipped: ` as a `WARN` and **does not abort startup** — mirroring the `verifySeed` "best-effort" pattern. Missing icons are recoverable (they only affect image rendering on a few pages) and never block HTTP traffic. # Source-of-truth coupling `CANONICAL_SYSTEM_ICON_KEYS` is duplicated from `CANONICAL_SYSTEM_CATEGORIES` in `backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories.ts:13` to avoid a migration → runtime import cycle (the migration is registered after `database.module.ts` is built, and re-importing from the migration would be circular). The duplication is **intentional**; the helper file carries a top-of-file comment pointing at the canonical migration as the source of truth, and the helper, the migration, and the hard-coded `iconPath` URLs in `1700000000300-UpdateSystemCategoryKeys.ts` and `1700000000400-RepairCategorySchemaAndSystemCategories.ts` must be kept in sync whenever a system category is added or renamed. # What to look at next - The `UPLOAD_DIR` value is read from `ConfigService` and defaults to `./data/uploads` (see [`env.schema.ts`](/architecture/key-files.md) for the canonical defaults). - The icons directory is served by `app.use('/uploads', express.static(uploadDir))` in `backend/src/main.ts:79`, so the on-disk files are the same bytes returned by `GET /uploads/icons/.png` to the SPA. - Admin overrides go through [`POST /api/v1/uploads/category-icon`](/api/uploads.md), which normalizes uploaded images to the same 128×128 PNG size and writes to the same `UPLOAD_DIR/icons/.png` path. - The challenges board and admin categories page consume `` from each `category.icon_path` column — see [Challenges Board](/guides/challenges-board.md) and [Admin — Categories](/guides/admin-categories.md). # Tests | File | What it asserts | |---|---| | `tests/backend/system-category-icons.spec.ts` | Real `sharp` + `os.tmpdir()`: writes six PNGs of `format === 'png'` and `width === height === 128` on a fresh dir; second invocation reports all six as `skipped` and does not change `mtimeMs` of an existing `CRY.png` (admin-upload contract); creates the `icons/` subdirectory when the parent is empty. No mocks. | # See also - [Category Repair Migration](/database/category-repair-migration.md) — canonical six system categories, DB-side. - [Uploads Endpoints](/api/uploads.md) — admin override path for the same on-disk files. - [Challenges Board](/guides/challenges-board.md) — tester-visible consumption of the icons. - [Admin — Categories](/guides/admin-categories.md) — admin override UI. - [Backend Module Map](/architecture/backend-modules.md) — startup chain. - [Key Files Index](/architecture/key-files.md) — file-by-file responsibilities.