AI Implementation feature(820): Project Scaffold and Platform Foundations (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
---
|
||||
type: database
|
||||
title: Auth and Settings Tables
|
||||
description: refresh_token and setting tables — rotating refresh tokens and site configuration.
|
||||
tags: [database, refresh-token, settings, config]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Tables
|
||||
|
||||
## `refresh_token`
|
||||
|
||||
| Column | Type | Description |
|
||||
|---------------|---------|------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `user_id` | TEXT | FK → `user.id` (indexed, but no DB-level FK cascade is configured). |
|
||||
| `token_hash` | TEXT | SHA-256 of the raw refresh token (unique). |
|
||||
| `issued_at` | TEXT | ISO 8601 timestamp. |
|
||||
| `expires_at` | TEXT | ISO 8601 timestamp; checked on refresh. |
|
||||
| `revoked_at` | TEXT | ISO 8601 timestamp or `NULL` if active. |
|
||||
|
||||
# Refresh token lifecycle
|
||||
|
||||
1. **Issue** — `AuthService.login` and `AuthService.registerFirstAdmin`
|
||||
mint a new token via `crypto.randomBytes(32).toString('base64url')`
|
||||
and store its SHA-256 hash with `expires_at = now + JWT_REFRESH_TTL`.
|
||||
The raw token is set as an HttpOnly `rt` cookie and also returned in
|
||||
the login response body.
|
||||
2. **Rotate** — `AuthService.refresh` looks up the row by `token_hash`,
|
||||
asserts it isn't revoked/expired, marks `revoked_at = now`, and mints
|
||||
a new token in the same transaction. Each token is single-use.
|
||||
3. **Logout** — `AuthService.logout` looks up by `token_hash` (from the
|
||||
`rt` cookie) and sets `revoked_at`. Already-revoked or unknown tokens
|
||||
are silently ignored.
|
||||
|
||||
## `setting`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|---------|-----------------------------------------------------|
|
||||
| `key` | TEXT PK | Settings key (see `SETTINGS_KEYS` below). |
|
||||
| `value`| TEXT | String value (defaults to empty string). |
|
||||
|
||||
Read/write access goes through `SettingsService`
|
||||
(`backend/src/modules/settings/settings.module.ts`), which exposes
|
||||
`get(key, fallback?)`, `set(key, value)`, and `getAll()`.
|
||||
|
||||
# Settings keys
|
||||
|
||||
Defined in `backend/src/config/env.schema.ts` (`SETTINGS_KEYS`) and
|
||||
seeded by `SeedSystemData1700000000100`:
|
||||
|
||||
| Key | Default | Consumed by |
|
||||
|-------------------------|------------------------------------------|-------------------------------------------------------------------|
|
||||
| `pageTitle` | `HIPCTF` | `SystemService.bootstrap` → `pageTitle` |
|
||||
| `logo` | `""` | `SystemService.bootstrap` → `logo` |
|
||||
| `welcomeMarkdown` | `"# Welcome\n\nCreate the first admin..."` | `SystemService.bootstrap` → `welcomeMarkdown` |
|
||||
| `themeKey` | `classic` | `SystemService.bootstrap` → `theme` (overridden via `ThemeLoaderService`) |
|
||||
| `defaultChallengeIp` | `127.0.0.1` | `SystemService.bootstrap` → `defaultChallengeIp` |
|
||||
| `registrationsEnabled` | `false` | `SystemService.bootstrap` → `registrationsEnabled` |
|
||||
| `eventStartUtc` | `now + 60s` (ISO) | `EventStatusService.getStatus` |
|
||||
| `eventEndUtc` | `now + 7d` (ISO) | `EventStatusService.getStatus` |
|
||||
|
||||
# See also
|
||||
|
||||
- [Database Schema Overview](/database/schema.md)
|
||||
- [User Table](/database/users.md)
|
||||
- [System Endpoints](/api/system.md)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
type: database
|
||||
title: Challenge Tables
|
||||
description: category, challenge, challenge_file, and solve tables — how CTF challenges and scoring are stored.
|
||||
tags: [database, challenge, category, solve]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Tables
|
||||
|
||||
## `category`
|
||||
|
||||
| Column | Type | Description |
|
||||
|----------------|---------|------------------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `system_key` | TEXT | One of `crypto`, `forensics`, `pwn`, `web`, `misc`, `osint` (unique where NOT NULL) or `NULL` for user-created categories. |
|
||||
| `name` | TEXT | Display name (e.g. `Cryptography`). |
|
||||
| `abbreviation` | TEXT | Short label (e.g. `CRY`). |
|
||||
| `description` | TEXT | Optional description. |
|
||||
| `icon_path` | TEXT | Default icon URL (e.g. `/uploads/icons/crypto.svg`). |
|
||||
|
||||
The seed migration inserts one row per `SYSTEM_CATEGORY_KEYS` value from
|
||||
`backend/src/config/env.schema.ts`.
|
||||
|
||||
## `challenge`
|
||||
|
||||
| Column | Type | Description |
|
||||
|-------------------|----------|-----------------------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `name` | TEXT | Display name. |
|
||||
| `description_md` | TEXT | Markdown description. |
|
||||
| `category_id` | TEXT | FK → `category.id`. |
|
||||
| `difficulty` | TEXT | `'low'` / `'med'` / `'high'`. |
|
||||
| `initial_points` | INTEGER | Starting point value. |
|
||||
| `minimum_points` | INTEGER | Floor after decay. |
|
||||
| `decay_solves` | INTEGER | Number of solves at which decay reaches the minimum. |
|
||||
| `flag` | TEXT | Submission string expected from players. |
|
||||
| `protocol` | TEXT | `'nc'` (netcat-style connection) or `'web'` (browser-based). |
|
||||
| `port` | INTEGER | TCP port when relevant (nullable). |
|
||||
| `ip_address` | TEXT | IP the challenge listens on (defaulted from `setting.defaultChallengeIp`). |
|
||||
| `created_at` | TEXT | ISO 8601 timestamp. |
|
||||
|
||||
## `challenge_file`
|
||||
|
||||
| Column | Type | Description |
|
||||
|---------------------|---------|--------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `challenge_id` | TEXT | FK → `challenge.id`. |
|
||||
| `original_filename` | TEXT | Filename from upload. |
|
||||
| `stored_path` | TEXT | Path on disk under `UPLOAD_DIR/challenges/`. |
|
||||
|
||||
## `solve`
|
||||
|
||||
| Column | Type | Description |
|
||||
|------------------|---------|----------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `challenge_id` | TEXT | FK → `challenge.id`. |
|
||||
| `user_id` | TEXT | FK → `user.id`. |
|
||||
| `solved_at` | TEXT | ISO 8601 timestamp the solve was recorded. |
|
||||
| `points_awarded` | INTEGER | Points actually awarded (after decay). |
|
||||
| `base_points` | INTEGER | Snapshot of `challenge.initial_points` at solve time. |
|
||||
| `rank_bonus` | INTEGER | Extra points from rank (e.g. first-blood). |
|
||||
|
||||
Uniqueness is enforced by `uq_solve_challenge_user` on
|
||||
`(challenge_id, user_id)` — a user can solve a given challenge only once.
|
||||
|
||||
# Scoring formula
|
||||
|
||||
`points_awarded = clamp(initial_points - decay, minimum_points, initial_points)`
|
||||
where `decay = max(0, base_points - minimum_points) * solvesBefore / decay_solves`,
|
||||
and additional `rank_bonus` may be added for early solves.
|
||||
|
||||
(The exact scoring algorithm lives in the future solve-recording service
|
||||
and is consumed by `SseHubService` whenever a new solve is published.)
|
||||
|
||||
# See also
|
||||
|
||||
- [Database Schema Overview](/database/schema.md)
|
||||
- [System Endpoints](/api/system.md) (SSE scoreboard stream)
|
||||
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
type: database
|
||||
title: Database Schema Overview
|
||||
description: SQLite (better-sqlite3) schema for HIPCTF: tables, relationships, indexes, and WAL journal mode.
|
||||
tags: [database, sqlite, typeorm, schema]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
HIPCTF uses **SQLite via better-sqlite3** with TypeORM migrations. The
|
||||
database is a single file (`DATABASE_PATH`, default
|
||||
`/data/hipctf/db.sqlite`) running in **WAL journal mode** for better
|
||||
concurrent read/write performance.
|
||||
|
||||
The schema is created by a single migration
|
||||
(`backend/src/database/migrations/1700000000000-InitSchema.ts`) and seeded
|
||||
by a second migration
|
||||
(`backend/src/database/migrations/1700000000100-SeedSystemData.ts`).
|
||||
|
||||
# Tables
|
||||
|
||||
| Table | Purpose |
|
||||
|-------------------|-------------------------------------------------------------|
|
||||
| `user` | Authenticated users with role + status. |
|
||||
| `setting` | Key/value site configuration. |
|
||||
| `category` | Challenge categories (system + user-defined). |
|
||||
| `challenge` | CTF challenges with scoring + protocol metadata. |
|
||||
| `challenge_file` | Files attached to challenges. |
|
||||
| `solve` | User-solved challenges (scoring ledger). |
|
||||
| `refresh_token` | Rotating refresh tokens (hash + revocation). |
|
||||
| `blog_post` | Markdown blog posts in draft/published states. |
|
||||
|
||||
# Relationships
|
||||
|
||||
```
|
||||
user ───< solve >─── challenge
|
||||
│
|
||||
└─── challenge_file >── challenge ──> category
|
||||
|
||||
user ───< refresh_token
|
||||
```
|
||||
|
||||
* `challenge.category_id` → `category.id` (`ON DELETE RESTRICT`).
|
||||
* `challenge_file.challenge_id` → `challenge.id` (`ON DELETE RESTRICT`).
|
||||
* `solve.challenge_id` → `challenge.id` (`ON DELETE RESTRICT`).
|
||||
* `solve.user_id` → `user.id` (`ON DELETE RESTRICT`).
|
||||
|
||||
# Indexes
|
||||
|
||||
| Table | Index |
|
||||
|------------------|----------------------------------------------------|
|
||||
| `category` | `uq_category_system_key` (unique on `system_key` where NOT NULL) |
|
||||
| `challenge` | `idx_challenge_category` |
|
||||
| `challenge_file` | `idx_challenge_file_challenge` |
|
||||
| `solve` | `uq_solve_challenge_user` (unique), `idx_solve_user`, `idx_solve_challenge` |
|
||||
| `refresh_token` | `idx_refresh_token_user`, unique on `token_hash` |
|
||||
| `blog_post` | `idx_blog_status_published` (`status`, `published_at`) |
|
||||
|
||||
# PRAGMAs
|
||||
|
||||
| PRAGMA | Where set | Effect |
|
||||
|--------------------|----------------------------------------------------|---------------------------------|
|
||||
| `foreign_keys = ON`| Migration `InitSchema1700000000000` | Enforce FK constraints. |
|
||||
| `journal_mode = WAL`| Migration + `DatabaseInitService.ensureWalMode()` on every startup | Persisted in DB header; allows concurrent readers. |
|
||||
|
||||
# Bootstrap behavior
|
||||
|
||||
On every process start, `DatabaseInitService.init()`:
|
||||
|
||||
1. Initializes the TypeORM `DataSource` (idempotent).
|
||||
2. Calls `ensureWalMode()` — idempotent; if WAL is already set it only logs.
|
||||
3. Runs pending migrations if any (or if the `category` table is missing).
|
||||
4. Logs the seed row counts (categories, settings) via `verifySeed()`.
|
||||
|
||||
# See also
|
||||
|
||||
- [User Table](/database/users.md)
|
||||
- [Challenge Tables](/database/challenges.md)
|
||||
- [Auth and Settings Tables](/database/auth-settings.md)
|
||||
- [Blog Post Table](/database/blog-posts.md)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: database
|
||||
title: User Table
|
||||
description: The `user` table — accounts, roles, and statuses.
|
||||
tags: [database, user, auth]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|----------------|----------|------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4 generated on creation. |
|
||||
| `username` | TEXT | Unique login name. |
|
||||
| `password_hash`| TEXT | Argon2id hash (memory/time/parallelism from env config). |
|
||||
| `role` | TEXT | `'admin'` or `'player'`. Default `'player'`. |
|
||||
| `status` | TEXT | `'enabled'` or `'disabled'`. Default `'enabled'`. |
|
||||
| `created_at` | TEXT | ISO 8601 timestamp (`strftime('%Y-%m-%dT%H:%M:%fZ','now')`). |
|
||||
|
||||
# Constraints
|
||||
|
||||
- Unique on `username`.
|
||||
- `role` is enforced application-side (see `AdminService.updateUserRole`).
|
||||
- `status` is checked by `AuthService.login` and `AuthService.refresh`
|
||||
— disabled users cannot log in or refresh.
|
||||
|
||||
# Invariants
|
||||
|
||||
- The application enforces the **last-admin invariant**: at least one
|
||||
enabled user with `role = 'admin'` must always exist. Attempts to
|
||||
demote or delete the last admin are rejected by
|
||||
`UsersService.enforceLastAdminInvariant` (used by
|
||||
`AdminService.updateUserRole` and `AdminService.deleteUser`).
|
||||
|
||||
# First-admin bootstrap
|
||||
|
||||
When the `user` table is empty of admins, `POST /api/v1/auth/register-first-admin`
|
||||
is the only endpoint that can create one. After that, the endpoint
|
||||
returns `409 SYSTEM_INITIALIZED`.
|
||||
|
||||
# See also
|
||||
|
||||
- [Auth and Settings Tables](/database/auth-settings.md)
|
||||
- [REST API Overview](/api/rest-overview.md)
|
||||
- [Admin Endpoints](/api/admin.md)
|
||||
Reference in New Issue
Block a user