AI Implementation feature(821): First-Start Create Admin Modal #2
@@ -1,101 +0,0 @@
|
||||
# Implementation Plan: WAL journal mode + OpenAPI 3.1 — Status check
|
||||
|
||||
## 0. Scope vs. Current Branch State
|
||||
|
||||
This reviewer request asks for three changes that were **already implemented
|
||||
and committed** in `de27a8b` on the `feature-820-1784637300954` branch. The
|
||||
code currently in the working tree (and in HEAD) matches the request verbatim:
|
||||
|
||||
1. ✅ `InitSchema` migration (`backend/src/database/migrations/1700000000000-InitSchema.ts`)
|
||||
already contains the WAL PRAGMA at line 8 — immediately after the existing
|
||||
`PRAGMA foreign_keys = ON` (line 7).
|
||||
2. ✅ `DatabaseInitService` (`backend/src/database/database-init.service.ts`)
|
||||
already enforces WAL on every startup via a private `ensureWalMode()`
|
||||
method that runs after `dataSource.initialize()`. It checks the current
|
||||
`journal_mode` and applies `PRAGMA journal_mode = WAL` if not already
|
||||
`wal`; idempotent and safe to call repeatedly.
|
||||
3. ✅ `main.ts` (`backend/src/main.ts` lines 83–94) now pipes the
|
||||
NestJS-generated OpenAPI document through a custom `toOpenApi31()`
|
||||
converter (`backend/src/common/utils/openapi31.ts`) that emits
|
||||
`openapi: '3.1.0'` and rewrites every legacy `nullable: true` marker
|
||||
into the JSON Schema 2020-12 type union.
|
||||
|
||||
Per the system instruction "If the feature is ALREADY fully implemented, you
|
||||
MUST include the exact marker text **[ALREADY_IMPLEMENTED]** at the top or in
|
||||
the status section of your plan file. Explain in the plan exactly which
|
||||
files/methods already contain the implementation", this plan documents the
|
||||
existing implementation rather than adding duplicate code.
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Migration** (`backend/src/database/migrations/1700000000000-InitSchema.ts:7-8`):
|
||||
```ts
|
||||
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
|
||||
await queryRunner.query(`PRAGMA journal_mode = WAL;`);
|
||||
```
|
||||
- **Warm-DB enforcement** (`backend/src/database/database-init.service.ts:34-78`):
|
||||
- `init()` calls `ensureWalMode()` after `dataSource.initialize()`.
|
||||
- `ensureWalMode()` queries `PRAGMA journal_mode`, switches to WAL if not
|
||||
already set, logs the transition.
|
||||
- **OpenAPI 3.1 pipeline** (`backend/src/main.ts:83-94`):
|
||||
```ts
|
||||
const swaggerConfig = new DocumentBuilder()...
|
||||
const rawDocument = SwaggerModule.createDocument(app, swaggerConfig) as Record<string, any>;
|
||||
const document = toOpenApi31(rawDocument);
|
||||
SwaggerModule.setup('api/docs', app, document, { jsonDocumentUrl: 'api/docs-json' });
|
||||
```
|
||||
- `backend/src/common/utils/openapi31.ts` exports `toOpenApi31(doc)`.
|
||||
- Rewrites `nullable: true` → `type: ['<orig>', 'null']` recursively in
|
||||
`components.schemas`, `paths`, parameters, request/response bodies,
|
||||
`properties`, `items`, `oneOf/anyOf/allOf`, and nested `schema` keys.
|
||||
- Preserves `$ref` nodes.
|
||||
- **Tests** (`tests/backend/openapi31.spec.ts`, 7 tests): all pass.
|
||||
- **Full suite**: 83/83 tests across 18 suites.
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
None — all changes are already in place at HEAD.
|
||||
|
||||
| File | Status |
|
||||
| --- | --- |
|
||||
| `backend/src/database/migrations/1700000000000-InitSchema.ts` | ✅ line 8 |
|
||||
| `backend/src/database/database-init.service.ts` | ✅ lines 34-78 |
|
||||
| `backend/src/common/utils/openapi31.ts` | ✅ exists (created in `de27a8b`) |
|
||||
| `backend/src/main.ts` | ✅ lines 83-94 |
|
||||
| `tests/backend/openapi31.spec.ts` | ✅ exists, 7 tests passing |
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
No code changes required. If a follow-up task wants to extend the existing
|
||||
implementation (e.g., add an `info.license` field, support `webhooks`, or
|
||||
add a Swagger UI bundle switch), that would be a separate plan.
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
- The existing `tests/backend/openapi31.spec.ts` (7 tests) locks the
|
||||
conversion behaviour.
|
||||
- The full suite `npm test` passes 83/83 across 18 suites.
|
||||
- A live-server smoke (documented in the previous turn's commit message)
|
||||
confirms `openapi: '3.1.0'`, 13 paths, zero `nullable: true` occurrences,
|
||||
WAL applied on cold start AND confirmed on warm restart.
|
||||
|
||||
## 5. Persistent `/data` Usage
|
||||
|
||||
No change. WAL persists to the file-backed SQLite DB; the SQLite WAL mode is
|
||||
persisted in the database header, so subsequent connections inherit it.
|
||||
|
||||
## 6. Definition of Done (already met)
|
||||
|
||||
- `git log --oneline -3` shows commit `de27a8b` "WAL journal mode + OpenAPI 3.1"
|
||||
- `npm test` → 83/83 passing
|
||||
- `npm run build` → backend + frontend succeed
|
||||
- `GET /api/docs-json` returns `openapi: "3.1.0"`
|
||||
- Cold start log: `PRAGMA journal_mode set (was='delete', now='wal')`
|
||||
- Warm start log: `PRAGMA journal_mode=wal (already set)`
|
||||
- Zero `nullable: true` occurrences in the served JSON document
|
||||
|
||||
**[ALREADY_IMPLEMENTED]** — All three requested changes exist on the branch
|
||||
in commits `de27a8b` (WAL + OpenAPI 3.1). No further code changes are needed
|
||||
unless the reviewer wants to extend the implementation with additional
|
||||
OpenAPI 3.1 features (webhooks, license, contact, etc.) or additional SQLite
|
||||
PRAGMAs.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Implementation Plan: Fix Angular build error — Property 'code' does not exist on type 'CreateAdminResult'
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Codebase style & conventions:** Angular 17 standalone components, signals, typed reactive forms. `frontend/tsconfig.json` uses `strict: false` and `strictTemplates: false`. The project still uses `@angular/compiler` (ngc) which performs its own type checking on top of TS narrowing rules.
|
||||
- **Data Layer:** N/A — this is a frontend-only type-narrowing fix.
|
||||
- **Test Framework & Structure:** `tests/jest.config.js`. Tests live under `tests/frontend/`. All compile via `npm test`.
|
||||
- **Required Tools & Dependencies:** No new dependencies. The fix is purely TypeScript / component-internal.
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
- **To Modify:** `/repo/frontend/src/app/features/setup/setup-create-admin.component.ts` — fix the type-narrowing that Angular's compiler refuses to accept.
|
||||
- **No new files, no test changes, no docs changes.**
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
### Root cause
|
||||
|
||||
In `setup-create-admin.component.ts:88-117` the `submit()` method awaits the service, then reads `result.ok`. After the `if (result.ok) { ... return; }` branch, TS should narrow `result` to `CreateAdminFailure` (which has `code`). However Angular's compiler (used by `ng build`) reports:
|
||||
|
||||
```
|
||||
TS2339: Property 'code' does not exist on type 'CreateAdminResult'.
|
||||
Property 'code' does not exist on type 'CreateAdminSuccess'.
|
||||
```
|
||||
|
||||
at the two reads `result.code` on lines 109 and 116. With `strict: false` + `strictTemplates: false`, narrowing across an awaited union *should* succeed, but Angular's compiler's narrowing model is stricter in some code paths (notably after `await` of an external service) and conservatively widens back to the full union. The error message proves it: type is `CreateAdminResult`, not `CreateAdminFailure`.
|
||||
|
||||
### Minimal fix
|
||||
|
||||
Replace the discriminated-union narrowing with an explicit type guard in the service and an `else` branch in the component. This avoids relying on the compiler's narrowing at all.
|
||||
|
||||
### Step-by-step
|
||||
|
||||
1. **Add a type predicate to the service** (`setup-create-admin.service.ts`):
|
||||
- Add `export function isCreateAdminFailure(r: CreateAdminResult): r is CreateAdminFailure { return !r.ok; }`.
|
||||
- No behaviour change.
|
||||
|
||||
2. **Refactor `submit()`** in `setup-create-admin.component.ts`:
|
||||
- Replace the `if (result.ok) { ... return; } ... if (result.code === ...) ...` sequence with an `if (result.ok) { ... return; } const failure = result;` pattern. Because `result.ok` is `true | false`, the negation narrows the local copy identically. The safest cross-compiler approach is:
|
||||
```ts
|
||||
if (result.ok) {
|
||||
this.auth.setSession(result.accessToken, result.user);
|
||||
this.bootstrap.markInitialized();
|
||||
await this.router.navigateByUrl('/challenges');
|
||||
return;
|
||||
}
|
||||
const failure: CreateAdminFailure = result; // explicit local of narrowed type
|
||||
if (failure.code === 'USERNAME_TAKEN') {
|
||||
this.serverError.set('Username already exists');
|
||||
this.serverErrorCode.set('USERNAME_TAKEN');
|
||||
return;
|
||||
}
|
||||
this.serverError.set('Setup failed, please retry');
|
||||
this.serverErrorCode.set(failure.code);
|
||||
```
|
||||
- Import `CreateAdminFailure` from `./setup-create-admin.service`.
|
||||
- The cast `const failure: CreateAdminFailure = result` after the `return` is safe (TS will assert assignability; if the union ever changes it becomes a compile error here rather than two ambiguous ones).
|
||||
|
||||
Alternatively, an explicit cast: `const failure = result as CreateAdminFailure;`. Either is acceptable; the `as`-cast is one token shorter and more idiomatic. Choose:
|
||||
|
||||
```ts
|
||||
const failure = result as CreateAdminFailure;
|
||||
```
|
||||
|
||||
3. **No HTML/CSS/service file changes.**
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
- **Target Unit Test File:** no changes. The existing `tests/frontend/setup-create-admin.spec.ts` already covers the validator and rules; the build error is a compile-time-only issue that the existing tests do not exercise.
|
||||
- **Mocking Strategy:** N/A.
|
||||
- **Verification:**
|
||||
1. `npm --workspace frontend run build` must complete without `TS2339` errors.
|
||||
2. `npm test` must still pass (20 suites, 97 tests).
|
||||
- **Do not** touch any test file. The fix is internal to the component.
|
||||
|
||||
## 5. Risk / Non-Goals
|
||||
|
||||
- **No behaviour change.** The runtime control flow is identical; the change only adds an explicit local that the compiler can definitively narrow.
|
||||
- **No public API change.** `CreateAdminResult`, `CreateAdminSuccess`, `CreateAdminFailure` shapes remain as defined.
|
||||
- **No docs change required** — implementation detail only.
|
||||
|
||||
## 6. Execution Checklist
|
||||
|
||||
1. Edit `setup-create-admin.component.ts`: import `CreateAdminFailure`, refactor lines 102-116 to use `const failure = result as CreateAdminFailure;` after the success branch returns.
|
||||
2. Run `npm --workspace frontend run build` and confirm 0 errors.
|
||||
3. Run `npm test` and confirm `Test Suites: 20 passed, 20 total`.
|
||||
@@ -6,6 +6,7 @@ import { DatabaseModule } from './database/database.module';
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { SetupModule } from './modules/setup/setup.module';
|
||||
import { SystemModule } from './modules/system/system.module';
|
||||
import { SettingsModule } from './modules/settings/settings.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
@@ -26,6 +27,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
SettingsModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
SetupModule,
|
||||
SystemModule,
|
||||
AdminModule,
|
||||
UploadsModule,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ERROR_CODES = {
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
CSRF_INVALID: 'CSRF_INVALID',
|
||||
WEAK_PASSWORD: 'WEAK_PASSWORD',
|
||||
USERNAME_TAKEN: 'USERNAME_TAKEN',
|
||||
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
|
||||
TOKEN_REVOKED: 'TOKEN_REVOKED',
|
||||
THEME_INVALID: 'THEME_INVALID',
|
||||
|
||||
@@ -8,7 +8,11 @@ import { ERROR_CODES } from '../errors/error-codes';
|
||||
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
const COOKIE_NAME = 'csrf';
|
||||
const HEADER_NAME = 'x-csrf-token';
|
||||
const SKIP_PATH_PREFIXES = ['/api/v1/auth/register-first-admin', '/api/v1/auth/login'];
|
||||
const SKIP_PATH_PREFIXES = [
|
||||
'/api/v1/auth/register-first-admin',
|
||||
'/api/v1/auth/login',
|
||||
'/api/v1/setup/create-admin',
|
||||
];
|
||||
|
||||
function isSkipped(path: string): boolean {
|
||||
return SKIP_PATH_PREFIXES.some((p) => path === p || path.startsWith(p + '/'));
|
||||
|
||||
@@ -136,11 +136,31 @@ export class AuthService implements OnModuleInit {
|
||||
* token so the caller can set the HttpOnly cookie.
|
||||
*/
|
||||
private async mintSession(manager: EntityManager, user: UserEntity): Promise<LoginResponse> {
|
||||
const accessToken = await this.signAccess(user);
|
||||
const refreshToken = this.generateRefreshToken();
|
||||
const tokenHash = this.hashToken(refreshToken);
|
||||
return AuthService.mintSessionWith(
|
||||
manager,
|
||||
this.jwt,
|
||||
this.config,
|
||||
this.refreshTtlSeconds,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateless mint helper used by other modules (e.g. setup) that need to
|
||||
* create a session without depending on the full AuthModule injection tree.
|
||||
*/
|
||||
static async mintSessionWith(
|
||||
manager: EntityManager,
|
||||
jwt: JwtService,
|
||||
config: ConfigService,
|
||||
refreshTtlSeconds: number,
|
||||
user: UserEntity,
|
||||
): Promise<LoginResponse> {
|
||||
const accessToken = await jwt.signAsync({ sub: user.id, username: user.username, role: user.role });
|
||||
const refreshToken = AuthService.generateRefreshTokenStatic();
|
||||
const tokenHash = AuthService.hashTokenStatic(refreshToken);
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + this.refreshTtlSeconds * 1000);
|
||||
const expires = new Date(now.getTime() + refreshTtlSeconds * 1000);
|
||||
await manager.insert(RefreshTokenEntity, {
|
||||
id: uuid(),
|
||||
userId: user.id,
|
||||
@@ -152,11 +172,46 @@ export class AuthService implements OnModuleInit {
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: this.accessTtlSeconds(),
|
||||
expiresIn: AuthService.accessTtlSecondsStatic(config),
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
};
|
||||
}
|
||||
|
||||
private static generateRefreshTokenStatic(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
private static hashTokenStatic(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
private static accessTtlSecondsStatic(config: ConfigService): number {
|
||||
const ttl = config.get<string>('JWT_ACCESS_TTL', '15m');
|
||||
return AuthService.parseTtlStatic(ttl);
|
||||
}
|
||||
|
||||
private static parseTtlStatic(ttl: string): number {
|
||||
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
|
||||
if (!m) return 900;
|
||||
const n = parseInt(m[1], 10);
|
||||
const unit = m[2];
|
||||
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
|
||||
return n * mult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public access to the static session mint for cross-module reuse.
|
||||
* The instance-based `mintSession(manager, user)` is preserved for
|
||||
* existing callers and delegates here.
|
||||
*/
|
||||
static async createSession(
|
||||
manager: EntityManager,
|
||||
deps: { jwt: JwtService; config: ConfigService; refreshTtlSeconds: number },
|
||||
user: UserEntity,
|
||||
): Promise<LoginResponse> {
|
||||
return AuthService.mintSessionWith(manager, deps.jwt, deps.config, deps.refreshTtlSeconds, user);
|
||||
}
|
||||
|
||||
private async signAccess(user: UserEntity): Promise<string> {
|
||||
return this.jwt.signAsync({ sub: user.id, username: user.username, role: user.role });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateAdminDtoSchema = z
|
||||
.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(32, 'Username must be at most 32 characters')
|
||||
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, digits, dot, dash and underscore'),
|
||||
password: z.string().min(1).max(256),
|
||||
passwordConfirm: z.string().min(1).max(256),
|
||||
})
|
||||
.refine((d) => d.password === d.passwordConfirm, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['passwordConfirm'],
|
||||
});
|
||||
|
||||
export type CreateAdminDto = z.infer<typeof CreateAdminDtoSchema>;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { SetupService } from './setup.service';
|
||||
import { CreateAdminDtoSchema } from './dto/create-admin.dto';
|
||||
import { setRefreshCookie } from '../auth/cookie';
|
||||
|
||||
@ApiTags('setup')
|
||||
@Controller('api/v1/setup')
|
||||
export class SetupController {
|
||||
constructor(
|
||||
private readonly setup: SetupService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('create-admin')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Create the very first administrator. Only callable while no admin exists; returns 404 once initialized.',
|
||||
})
|
||||
async createAdmin(
|
||||
@Body(new ZodValidationPipe(CreateAdminDtoSchema))
|
||||
body: { username: string; password: string; passwordConfirm: string },
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
|
||||
const session = await this.setup.createAdmin(
|
||||
body.username,
|
||||
body.password,
|
||||
body.passwordConfirm,
|
||||
ip,
|
||||
);
|
||||
setRefreshCookie(res, session.refreshToken, this.config);
|
||||
return {
|
||||
accessToken: session.accessToken,
|
||||
expiresIn: session.expiresIn,
|
||||
user: session.user,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
|
||||
import { SetupController } from './setup.controller';
|
||||
import { SetupService } from './setup.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_ACCESS_SECRET'),
|
||||
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [SetupController],
|
||||
providers: [SetupService],
|
||||
})
|
||||
export class SetupModule {}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||
import { validatePassword } from '../../common/utils/password-policy';
|
||||
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import type { LoginResponse } from '../auth/dto/auth.dto';
|
||||
|
||||
const REFRESH_TTL_DEFAULT = 7 * 24 * 60 * 60;
|
||||
|
||||
@Injectable()
|
||||
export class SetupService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SetupService.name);
|
||||
private refreshTtlSeconds = REFRESH_TTL_DEFAULT;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
|
||||
@InjectRepository(RefreshTokenEntity) private readonly refreshTokens: Repository<RefreshTokenEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly registrationRateLimit: RegistrationRateLimitService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const ttl = this.config.get<string>('JWT_REFRESH_TTL', '7d');
|
||||
this.refreshTtlSeconds = this.parseTtl(ttl);
|
||||
}
|
||||
|
||||
async createAdmin(
|
||||
username: string,
|
||||
password: string,
|
||||
passwordConfirm: string,
|
||||
ip: string,
|
||||
): Promise<LoginResponse> {
|
||||
if (password !== passwordConfirm) {
|
||||
throw ApiError.validation('Passwords do not match');
|
||||
}
|
||||
|
||||
validatePassword(password, this.config);
|
||||
|
||||
if (!this.registrationRateLimit.isAllowed(ip)) {
|
||||
throw new ApiError(
|
||||
ERROR_CODES.RATE_LIMITED,
|
||||
'Too many registrations from this IP; try again later.',
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const adminCount = await manager.count(UserEntity, { where: { role: 'admin' } });
|
||||
if (adminCount > 0) {
|
||||
throw ApiError.notFound('Setup endpoint not available');
|
||||
}
|
||||
|
||||
const existing = await manager.findOne(UserEntity, { where: { username } });
|
||||
if (existing) {
|
||||
throw ApiError.conflict(ERROR_CODES.USERNAME_TAKEN, 'Username already exists');
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
|
||||
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
|
||||
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
|
||||
});
|
||||
|
||||
const user = manager.create(UserEntity, {
|
||||
id: uuid(),
|
||||
username,
|
||||
passwordHash,
|
||||
role: 'admin',
|
||||
status: 'enabled',
|
||||
});
|
||||
await manager.save(user);
|
||||
|
||||
this.registrationRateLimit.record(ip);
|
||||
this.logger.log(`First admin created (username=${username})`);
|
||||
|
||||
return AuthService.createSession(
|
||||
manager,
|
||||
{ jwt: this.jwt, config: this.config, refreshTtlSeconds: this.refreshTtlSeconds },
|
||||
user,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private parseTtl(ttl: string): number {
|
||||
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
|
||||
if (!m) return REFRESH_TTL_DEFAULT;
|
||||
const n = parseInt(m[1], 10);
|
||||
const unit = m[2];
|
||||
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
|
||||
return n * mult;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
@@ -6,6 +7,12 @@ import { SettingEntity } from '../../database/entities/setting.entity';
|
||||
import { ThemeLoaderService } from '../../common/utils/theme-loader.service';
|
||||
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||
|
||||
export interface PasswordPolicyInfo {
|
||||
minLength: number;
|
||||
requireMixed: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BootstrapPayload {
|
||||
initialized: boolean;
|
||||
pageTitle: string;
|
||||
@@ -14,6 +21,7 @@ export interface BootstrapPayload {
|
||||
theme: any;
|
||||
defaultChallengeIp: string;
|
||||
registrationsEnabled: boolean;
|
||||
passwordPolicy: PasswordPolicyInfo;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -22,6 +30,7 @@ export class SystemService {
|
||||
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
|
||||
@InjectRepository(SettingEntity) private readonly settings: Repository<SettingEntity>,
|
||||
private readonly themes: ThemeLoaderService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async bootstrap(): Promise<BootstrapPayload> {
|
||||
@@ -35,9 +44,19 @@ export class SystemService {
|
||||
theme: this.themes.getTheme(themeKey),
|
||||
defaultChallengeIp: await this.getSetting(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, '127.0.0.1'),
|
||||
registrationsEnabled: (await this.getSetting(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true',
|
||||
passwordPolicy: this.passwordPolicy(),
|
||||
};
|
||||
}
|
||||
|
||||
private passwordPolicy(): PasswordPolicyInfo {
|
||||
const minLength = this.config.get<number>('PASSWORD_MIN_LENGTH', 12);
|
||||
const requireMixed = this.config.get<boolean>('PASSWORD_REQUIRE_MIXED', true);
|
||||
const description = requireMixed
|
||||
? `At least ${minLength} characters with upper, lower, digit and symbol`
|
||||
: `At least ${minLength} characters`;
|
||||
return { minLength, requireMixed, description };
|
||||
}
|
||||
|
||||
private async getSetting(key: string, fallback: string): Promise<string> {
|
||||
const row = await this.settings.findOne({ where: { key } });
|
||||
return row?.value ?? fallback;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
type: api
|
||||
title: REST API Overview
|
||||
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
|
||||
tags: [api, rest, overview, csrf]
|
||||
timestamp: 2026-07-21T14:43:00Z
|
||||
---
|
||||
|
||||
# Base URL & versioning
|
||||
|
||||
All endpoints live under `/api/v1`. The Angular SPA uses the same
|
||||
prefix; the SPA fallback only kicks in for non-API routes.
|
||||
|
||||
| Path prefix | Purpose |
|
||||
|-----------------|--------------------------------------------------------|
|
||||
| `/api/v1/*` | Versioned JSON REST endpoints. |
|
||||
| `/api/docs` | Swagger UI (OpenAPI 3.1). |
|
||||
| `/api/docs-json`| Raw OpenAPI 3.1 document. |
|
||||
| `/uploads/*` | Static uploads directory (`UPLOAD_DIR`). |
|
||||
| `/*` | SPA fallback (`FRONTEND_DIST/index.html`). |
|
||||
|
||||
# Authentication
|
||||
|
||||
* `JwtAuthGuard` is registered as `APP_GUARD` and runs on every request
|
||||
unless the handler is marked with `@Public()` from
|
||||
`backend/src/common/decorators/public.decorator.ts`.
|
||||
* Successful auth produces an access JWT in the response body and an
|
||||
`HttpOnly` refresh cookie named `rt` (see `backend/src/modules/auth/cookie.ts`).
|
||||
* `Authorization: Bearer <accessToken>` is attached by the
|
||||
`authInterceptor` on the frontend.
|
||||
|
||||
# CSRF
|
||||
|
||||
* `CsrfMiddleware` (registered globally) mints a `csrf` cookie on every
|
||||
response and, for unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`),
|
||||
compares the cookie against the `x-csrf-token` header using
|
||||
`crypto.timingSafeEqual`.
|
||||
* The frontend's `csrfInterceptor` runs *before* `authInterceptor` so
|
||||
the header is attached on every unsafe call.
|
||||
* Skip list (`SKIP_PATH_PREFIXES` in
|
||||
`backend/src/common/middleware/csrf.middleware.ts`):
|
||||
- `/api/v1/auth/register-first-admin`
|
||||
- `/api/v1/auth/login`
|
||||
- `/api/v1/setup/create-admin`
|
||||
|
||||
# Error envelope
|
||||
|
||||
Every error flows through `GlobalExceptionFilter` and is shaped as:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "USERNAME_TAKEN",
|
||||
"message": "Username already exists",
|
||||
"details": null,
|
||||
"path": "/api/v1/setup/create-admin",
|
||||
"timestamp": "2026-07-21T14:43:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------------|---------------------|--------------------------------------------------------|
|
||||
| `code` | string | One of `ERROR_CODES` (`backend/src/common/errors/error-codes.ts`). |
|
||||
| `message` | string | Human-readable summary. |
|
||||
| `details` | unknown \| null | Optional structured details (e.g. zod field errors). |
|
||||
| `path` | string | Original request path. |
|
||||
| `timestamp` | ISO 8601 string | When the filter produced the response. |
|
||||
|
||||
# Canonical error codes
|
||||
|
||||
`USERNAME_TAKEN` is the most recently added code (used by the setup
|
||||
endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
|
||||
|
||||
`VALIDATION_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`,
|
||||
`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`,
|
||||
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
|
||||
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`, `INTERNAL`.
|
||||
|
||||
# See also
|
||||
|
||||
- [Auth Endpoints](/api/auth.md)
|
||||
- [Admin Endpoints](/api/admin.md)
|
||||
- [Setup Endpoint](/api/setup.md)
|
||||
- [System Endpoints](/api/system.md)
|
||||
- [Uploads Endpoints](/api/uploads.md)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
type: api
|
||||
title: Setup Endpoint
|
||||
description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists.
|
||||
tags: [api, setup, bootstrap, first-admin]
|
||||
timestamp: 2026-07-21T14:43:00Z
|
||||
---
|
||||
|
||||
# Endpoint
|
||||
|
||||
| Method | Path | Auth | CSRF | Rate limited |
|
||||
|--------|-------------------------------|------|--------|--------------|
|
||||
| `POST` | `/api/v1/setup/create-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`) | Yes (per-IP via `RegistrationRateLimitService`) |
|
||||
|
||||
# Request body
|
||||
|
||||
Validated by `backend/src/modules/setup/dto/create-admin.dto.ts` (zod):
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|-------------------|--------|------------------------------------------------------------------------------|
|
||||
| `username` | string | 3–32 chars, regex `^[a-zA-Z0-9_.-]+$` |
|
||||
| `password` | string | 1–256 chars; additionally passes `validatePassword` (Argon2 policy). |
|
||||
| `passwordConfirm` | string | Must equal `password` (zod refine → `VALIDATION_FAILED`, `passwordConfirm`).|
|
||||
|
||||
# Successful response (201)
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "<jwt>",
|
||||
"expiresIn": 900,
|
||||
"user": { "id": "<uuid>", "username": "first.admin", "role": "admin" }
|
||||
}
|
||||
```
|
||||
|
||||
The refresh token is set as an `HttpOnly` cookie via
|
||||
`setRefreshCookie(res, session.refreshToken, config)` so the SPA can keep
|
||||
refreshing without storing the raw token in JS.
|
||||
|
||||
# Error envelope
|
||||
|
||||
| HTTP | `code` | When |
|
||||
|------|---------------------|----------------------------------------------------------------------|
|
||||
| 400 | `VALIDATION_FAILED` | zod body validation failed (length, regex, or `passwordConfirm`). |
|
||||
| 400 | `WEAK_PASSWORD` | Argon2 policy rejects the password (length / mixed-case requirement). |
|
||||
| 404 | `NOT_FOUND` | An admin already exists — the route is hidden once initialized. |
|
||||
| 409 | `USERNAME_TAKEN` | Username collision (reachable only during a race before init). |
|
||||
| 429 | `RATE_LIMITED` | `RegistrationRateLimitService` blocked the IP. |
|
||||
|
||||
# Wiring
|
||||
|
||||
* Registered in `backend/src/app.module.ts` via `SetupModule`.
|
||||
* `SetupController` (`backend/src/modules/setup/setup.controller.ts`) is
|
||||
`@Public()` and uses `ZodValidationPipe(CreateAdminDtoSchema)`.
|
||||
* `SetupService.createAdmin` runs everything inside a single
|
||||
`DataSource.transaction`: admin count check → username uniqueness check
|
||||
→ Argon2id hash → insert `UserEntity(role='admin', status='enabled')` →
|
||||
mint session via the static `AuthService.createSession`.
|
||||
* CSRF is bypassed for `/api/v1/setup/create-admin` so the first caller
|
||||
(before any cookie exists) is not blocked. The middleware still mints
|
||||
a `csrf` cookie on the response so subsequent calls are covered.
|
||||
|
||||
# Frontend consumer
|
||||
|
||||
`frontend/src/app/features/setup/setup-create-admin.service.ts` calls
|
||||
this endpoint and normalizes the response into the discriminated union
|
||||
`CreateAdminResult`:
|
||||
|
||||
```ts
|
||||
type CreateAdminResult = CreateAdminSuccess | CreateAdminFailure;
|
||||
```
|
||||
|
||||
`CreateAdminFailure` exposes `{ ok: false, status, code, message }` so
|
||||
the component can switch on `code === 'USERNAME_TAKEN'` to surface a
|
||||
non-retryable inline error and offer a Retry button for everything else.
|
||||
|
||||
# See also
|
||||
|
||||
- [First-Run Bootstrap Guide](/guides/bootstrap.md)
|
||||
- [Backend Module Map](/architecture/backend-modules.md)
|
||||
- [Bootstrap Service](/architecture/frontend-structure.md)
|
||||
@@ -3,7 +3,7 @@ type: architecture
|
||||
title: Backend Module Map
|
||||
description: NestJS modules, controllers, services, and how they are wired together.
|
||||
tags: [architecture, backend, nestjs, modules]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
timestamp: 2026-07-21T14:43:00Z
|
||||
---
|
||||
|
||||
# Module Map
|
||||
@@ -25,6 +25,7 @@ also registers two global providers:
|
||||
| `SettingsModule` | `backend/src/modules/settings/settings.module.ts` | Exposes `SettingsService` (get/set/getAll over `setting` table). |
|
||||
| `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/*`) + `AuthService` (login/refresh/logout/register-first-admin).|
|
||||
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant). |
|
||||
| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 404 once any admin exists (route hidden). |
|
||||
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
|
||||
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. |
|
||||
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
|
||||
@@ -36,6 +37,7 @@ also registers two global providers:
|
||||
|------------------------|-------------------------|--------------|--------------------------------------------------------------|
|
||||
| `AuthController` | `/api/v1/auth` | Mostly public (login, refresh, logout, csrf) | `backend/src/modules/auth/auth.controller.ts` |
|
||||
| `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` |
|
||||
| `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 404 once an admin exists) | `backend/src/modules/setup/setup.controller.ts` |
|
||||
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
|
||||
| `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` |
|
||||
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
|
||||
|
||||
@@ -3,7 +3,7 @@ type: architecture
|
||||
title: Frontend Structure
|
||||
description: Angular routes, components, services, guards, and interceptors.
|
||||
tags: [architecture, frontend, angular]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
timestamp: 2026-07-21T14:43:00Z
|
||||
---
|
||||
|
||||
# Routes
|
||||
@@ -12,7 +12,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
||||
|
||||
| Path | Component | Guard | Notes |
|
||||
|--------------|----------------------------------|---------------|----------------------------------------|
|
||||
| `/bootstrap` | `CreateAdminComponent` | — | Rendered only when `initialized === false`. |
|
||||
| `/bootstrap` | `SetupCreateAdminComponent` | — | Rendered only when `initialized === false`. Non-dismissible modal overlay. |
|
||||
| `/login` | `LoginComponent` | — | Standard login form. |
|
||||
| `/` | `HomeComponent` | `authGuard` | Requires auth + initialized. |
|
||||
| `**` | Redirect to `/` | — | Wildcard fallback. |
|
||||
@@ -27,7 +27,7 @@ All components are standalone (no NgModules). Each component imports
|
||||
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>` and calls `BootstrapService.load()`. |
|
||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Landing page shown after auth. |
|
||||
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
|
||||
| `CreateAdminComponent` | `frontend/src/app/features/bootstrap/create-admin.component.ts`| First-admin bootstrap form. |
|
||||
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal (non-dismissible, typed reactive forms). |
|
||||
|
||||
# Services
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: architecture
|
||||
title: Key Files Index
|
||||
description: One-line responsibility for every important source file in the repository.
|
||||
tags: [architecture, index, key-files]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
timestamp: 2026-07-21T14:43:00Z
|
||||
---
|
||||
|
||||
# Backend
|
||||
@@ -74,6 +74,10 @@ timestamp: 2026-07-21T14:18:00Z
|
||||
| `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. |
|
||||
| `backend/src/modules/users/users.service.ts` | `enforceLastAdminInvariant` helper. |
|
||||
| `backend/src/modules/users/dto/create-first-admin.dto.ts` | Zod schema for first-admin registration. |
|
||||
| `backend/src/modules/setup/setup.module.ts` | Wires `SetupController` + `SetupService` (dedicated first-admin bootstrap flow). |
|
||||
| `backend/src/modules/setup/setup.controller.ts` | `POST /api/v1/setup/create-admin` (returns 404 once an admin exists). |
|
||||
| `backend/src/modules/setup/setup.service.ts` | Hashes password (Argon2id), runs the create inside a transaction, mints session via `AuthService.createSession`. |
|
||||
| `backend/src/modules/setup/dto/create-admin.dto.ts` | Zod schema with `password === passwordConfirm` refine. |
|
||||
| `backend/src/modules/admin/admin.module.ts` | Wires `AdminController` + `AdminService`. |
|
||||
| `backend/src/modules/admin/admin.controller.ts` | `/api/v1/admin/users[/{id}]`. |
|
||||
| `backend/src/modules/admin/admin.service.ts` | list / create / update role / delete user with last-admin guard. |
|
||||
@@ -110,7 +114,9 @@ timestamp: 2026-07-21T14:18:00Z
|
||||
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
|
||||
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. |
|
||||
| `frontend/src/app/features/auth/login.component.ts` | Login form. |
|
||||
| `frontend/src/app/features/bootstrap/create-admin.component.ts` | First-admin creation form. |
|
||||
| `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin modal overlay (non-dismissible; typed reactive form). |
|
||||
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. |
|
||||
| `frontend/src/app/features/setup/setup-create-admin.validators.ts` | Standalone `passwordMatchValidator` group-level reactive form validator. |
|
||||
| `frontend/src/app/features/home/home.component.ts` | Landing page after auth. |
|
||||
|
||||
# Tests
|
||||
|
||||
+11
-4
@@ -3,7 +3,7 @@ type: database
|
||||
title: User Table
|
||||
description: The `user` table — accounts, roles, and statuses.
|
||||
tags: [database, user, auth]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
timestamp: 2026-07-21T14:43:00Z
|
||||
---
|
||||
|
||||
# Schema
|
||||
@@ -34,9 +34,16 @@ timestamp: 2026-07-21T14:18:00Z
|
||||
|
||||
# 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`.
|
||||
When the `user` table is empty of admins, the dedicated
|
||||
`POST /api/v1/setup/create-admin` endpoint (see
|
||||
[Setup Endpoint](/api/setup.md)) creates the very first admin and
|
||||
auto-issues a session. Once any admin row exists the endpoint becomes
|
||||
inert and returns `404 NOT_FOUND`, so the route is effectively hidden
|
||||
in production.
|
||||
|
||||
The legacy `POST /api/v1/auth/register-first-admin` route is preserved
|
||||
for compatibility but the canonical first-admin flow now lives in the
|
||||
`SetupModule`.
|
||||
|
||||
# See also
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
type: guide
|
||||
title: First-Run Bootstrap
|
||||
description: How a fresh HIPCTF instance is initialized by the very first administrator.
|
||||
tags: [guide, bootstrap, first-admin, onboarding, tester]
|
||||
timestamp: 2026-07-21T14:43:00Z
|
||||
---
|
||||
|
||||
# When this flow runs
|
||||
|
||||
A brand-new deployment shows a non-dismissible modal asking the operator
|
||||
to create the very first admin user. This is gated by the
|
||||
`initialized` flag returned from `GET /api/v1/bootstrap`:
|
||||
|
||||
| `initialized` | User experience |
|
||||
|---------------|---------------------------------------------------------------|
|
||||
| `false` | Every route is redirected to `/bootstrap` until an admin is created. |
|
||||
| `true` | The setup route becomes a 404 and the modal can never appear again. |
|
||||
|
||||
The `authGuard` enforces the redirect on the client side; the backend
|
||||
independently rejects `/api/v1/setup/create-admin` with `404 NOT_FOUND`
|
||||
once any admin row exists, so the flow is safe even if the SPA is
|
||||
bypassed.
|
||||
|
||||
# How to access (tester steps)
|
||||
|
||||
1. Start the stack against an empty database:
|
||||
```
|
||||
npm run db:reset && npm run dev
|
||||
```
|
||||
2. Open the browser at `http://localhost:3000/`.
|
||||
3. The app redirects to `/bootstrap` and shows the
|
||||
**"Welcome to HIPCTF — Create the first administrator"** modal.
|
||||
4. Fill in the three fields and submit:
|
||||
- **Username** — 3–32 chars, letters/digits/`.`/`-`/`_` only. Helper
|
||||
text is shown via `data-testid="setup-username-error"` when the
|
||||
pattern fails.
|
||||
- **Password** — must satisfy the policy shown directly below the
|
||||
field (default: 12 chars with upper, lower, digit, symbol).
|
||||
- **Confirm Password** — must equal the password above (verified by
|
||||
the group-level `passwordMatchValidator`; mismatch shows
|
||||
`data-testid="setup-confirm-error"`).
|
||||
5. Click **Create admin** (`data-testid="setup-submit"`).
|
||||
|
||||
# Expected behavior
|
||||
|
||||
* On success the modal disappears, the app stores the access token via
|
||||
`AuthService.setSession`, calls `BootstrapService.markInitialized()`,
|
||||
and navigates to `/challenges`.
|
||||
* On `USERNAME_TAKEN` the form shows a red alert:
|
||||
`"Username already exists"`. The **Retry** button is intentionally
|
||||
hidden because the username must be changed first.
|
||||
* On any other failure (network, `WEAK_PASSWORD`, `VALIDATION_FAILED`,
|
||||
`RATE_LIMITED`) the alert shows `"Setup failed, please retry"` and a
|
||||
**Retry** link (`data-testid="setup-retry"`) re-submits the same
|
||||
payload.
|
||||
|
||||
The modal **cannot** be dismissed by the user:
|
||||
|
||||
* Clicking the backdrop is a no-op (`swallowBackdrop`).
|
||||
* Pressing **Esc** is swallowed by the `keydown.escape` host listener.
|
||||
|
||||
# Visual elements
|
||||
|
||||
| Element | Selector | Purpose |
|
||||
|--------------------------------|----------------------------------|-----------------------------------------------|
|
||||
| Modal overlay | `.overlay` | Full-screen backdrop. |
|
||||
| Modal card | `.modal[role="dialog"]` | Contains the form; stops click propagation. |
|
||||
| Username input | `[data-testid="setup-username"]` | Reactive control with length/pattern rules. |
|
||||
| Password input | `[data-testid="setup-password"]` | Reactive control. |
|
||||
| Confirm input | `[data-testid="setup-password-confirm"]` | Reactive control. |
|
||||
| Submit button | `[data-testid="setup-submit"]` | Disabled while submitting or invalid. |
|
||||
| Server error alert | `[data-testid="setup-server-error"]` | Displays `serverError()`. |
|
||||
| Retry button | `[data-testid="setup-retry"]` | Re-submits; absent when the error is non-retryable. |
|
||||
| Password policy hint | `.hint` | Live description from `BootstrapService.passwordPolicy()`. |
|
||||
|
||||
# Behind the scenes (architecture map)
|
||||
|
||||
| Step | Where | What happens |
|
||||
|------|--------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| 1 | `BootstrapService.load()` | Calls `GET /api/v1/bootstrap`, sets `initialized = false`. |
|
||||
| 2 | `authGuard` | Sees `initialized() === false`, redirects to `/bootstrap`. |
|
||||
| 3 | `SetupCreateAdminComponent` | Renders the modal; reactive form enforces username + password policy + passwordMatch. |
|
||||
| 4 | `SetupCreateAdminService.ensureCsrf()` | Preflights `GET /api/v1/auth/csrf` so the response sets the `csrf` cookie. |
|
||||
| 5 | `POST /api/v1/setup/create-admin` | `SetupService.createAdmin` runs in a TypeORM transaction; creates the user with `role='admin'`; mints session via `AuthService.createSession`. |
|
||||
| 6 | `setRefreshCookie` | Sets the `rt` HttpOnly cookie. |
|
||||
| 7 | Component on success | `AuthService.setSession`, `BootstrapService.markInitialized()`, navigate to `/challenges`. |
|
||||
| 8 | Subsequent `authGuard` checks | `initialized === true`; require auth instead. |
|
||||
|
||||
# What the operator sees after init
|
||||
|
||||
Once the first admin exists, `/api/v1/bootstrap` returns
|
||||
`initialized: true`. The app now:
|
||||
|
||||
* Skips `/bootstrap` entirely.
|
||||
* Routes unauthenticated users to `/login`.
|
||||
* Routes authenticated users to `/` (Home) which itself redirects to
|
||||
`/challenges` once admin features are wired.
|
||||
|
||||
# See also
|
||||
|
||||
- [Setup Endpoint](/api/setup.md)
|
||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||
- [Backend Module Map](/architecture/backend-modules.md)
|
||||
- [Bootstrap payload source of truth](`/architecture/overview.md`)
|
||||
+3
-1
@@ -40,6 +40,8 @@ they need.
|
||||
CSRF, and error envelope.
|
||||
* [Auth Endpoints](/api/auth.md) - Login, refresh, logout, CSRF, and
|
||||
first-admin registration.
|
||||
* [Setup Endpoint](/api/setup.md) - Dedicated `POST /api/v1/setup/create-admin`
|
||||
used only while no administrator exists.
|
||||
* [Admin Endpoints](/api/admin.md) - User management endpoints
|
||||
(admin role required).
|
||||
* [System Endpoints](/api/system.md) - Bootstrap, event status, and SSE
|
||||
@@ -49,7 +51,7 @@ they need.
|
||||
# Guides
|
||||
|
||||
* [First-Run Bootstrap](/guides/bootstrap.md) - How a fresh instance is
|
||||
initialized by the very first admin.
|
||||
initialized by the very first admin via the non-dismissible modal.
|
||||
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
||||
* [Event Window](/guides/event-window.md) - How the event window and live
|
||||
countdown work.
|
||||
|
||||
@@ -4,7 +4,10 @@ import { authGuard } from './core/guards/auth.guard';
|
||||
export const APP_ROUTES: Routes = [
|
||||
{
|
||||
path: 'bootstrap',
|
||||
loadComponent: () => import('./features/bootstrap/create-admin.component').then((m) => m.CreateAdminComponent),
|
||||
loadComponent: () =>
|
||||
import('./features/setup/setup-create-admin.component').then(
|
||||
(m) => m.SetupCreateAdminComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
export interface PasswordPolicyInfo {
|
||||
minLength: number;
|
||||
requireMixed: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BootstrapPayload {
|
||||
initialized: boolean;
|
||||
@@ -8,12 +14,22 @@ export interface BootstrapPayload {
|
||||
theme: any;
|
||||
defaultChallengeIp: string;
|
||||
registrationsEnabled: boolean;
|
||||
passwordPolicy: PasswordPolicyInfo;
|
||||
}
|
||||
|
||||
const DEFAULT_POLICY: PasswordPolicyInfo = {
|
||||
minLength: 12,
|
||||
requireMixed: true,
|
||||
description: 'At least 12 characters with upper, lower, digit and symbol',
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BootstrapService {
|
||||
readonly payload = signal<BootstrapPayload | null>(null);
|
||||
readonly initialized = signal<boolean>(false);
|
||||
readonly passwordPolicy = computed<PasswordPolicyInfo>(
|
||||
() => this.payload()?.passwordPolicy ?? DEFAULT_POLICY,
|
||||
);
|
||||
|
||||
async load(): Promise<BootstrapPayload | null> {
|
||||
try {
|
||||
@@ -29,6 +45,10 @@ export class BootstrapService {
|
||||
}
|
||||
}
|
||||
|
||||
markInitialized(): void {
|
||||
this.initialized.set(true);
|
||||
}
|
||||
|
||||
private applyTheme(theme: any): void {
|
||||
if (!theme?.tokens) return;
|
||||
const root = document.documentElement.style;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-family, system-ui, sans-serif);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
background: var(--color-surface, #1b1b1f);
|
||||
color: var(--color-text, #f5f5f7);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
padding: 28px 28px 24px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.modal h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text, #f5f5f7);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field input {
|
||||
font: inherit;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: var(--color-primary, #4f8cff);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--color-danger, #ff6b6b);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.server-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 107, 107, 0.12);
|
||||
border: 1px solid rgba(255, 107, 107, 0.35);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--color-danger, #ff6b6b);
|
||||
}
|
||||
|
||||
.server-error .link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
margin-top: 6px;
|
||||
padding: 11px 14px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
border: 0;
|
||||
background: var(--color-primary, #4f8cff);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button.primary[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<div class="overlay" (click)="swallowBackdrop($event)">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="setup-title" (click)="$event.stopPropagation()">
|
||||
<h1 id="setup-title">Welcome to HIPCTF — Create the first administrator</h1>
|
||||
<p class="subtitle">This instance has not been initialized yet.</p>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
formControlName="username"
|
||||
[attr.aria-invalid]="!!usernameErrors()"
|
||||
data-testid="setup-username"
|
||||
/>
|
||||
@if (usernameErrors()) {
|
||||
<small class="field-error" data-testid="setup-username-error">{{ usernameErrors() }}</small>
|
||||
}
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="password"
|
||||
data-testid="setup-password"
|
||||
/>
|
||||
<small class="hint">{{ policyDescription() }}</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Confirm Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="passwordConfirm"
|
||||
data-testid="setup-password-confirm"
|
||||
/>
|
||||
@if (confirmErrors()) {
|
||||
<small class="field-error" data-testid="setup-confirm-error">{{ confirmErrors() }}</small>
|
||||
}
|
||||
</label>
|
||||
|
||||
@if (serverError()) {
|
||||
<div class="server-error" role="alert" data-testid="setup-server-error">
|
||||
<span>{{ serverError() }}</span>
|
||||
@if (canRetry()) {
|
||||
<button type="button" class="link" (click)="submit()" data-testid="setup-retry">Retry</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
[disabled]="submitting() || form.invalid"
|
||||
data-testid="setup-submit"
|
||||
>
|
||||
@if (submitting()) {
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Creating...</span>
|
||||
} @else {
|
||||
<span>Create admin</span>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||
import { SetupCreateAdminService, CreateAdminFailure } from './setup-create-admin.service';
|
||||
import { passwordMatchValidator } from './setup-create-admin.validators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-setup-create-admin',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './setup-create-admin.component.html',
|
||||
styleUrls: ['./setup-create-admin.component.css'],
|
||||
})
|
||||
export class SetupCreateAdminComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly api = inject(SetupCreateAdminService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly bootstrap = inject(BootstrapService);
|
||||
|
||||
readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description);
|
||||
|
||||
readonly form = this.fb.nonNullable.group(
|
||||
{
|
||||
username: this.fb.nonNullable.control('', [
|
||||
Validators.required,
|
||||
Validators.minLength(3),
|
||||
Validators.maxLength(32),
|
||||
Validators.pattern(/^[a-zA-Z0-9_.-]+$/),
|
||||
]),
|
||||
password: this.fb.nonNullable.control('', [Validators.required]),
|
||||
passwordConfirm: this.fb.nonNullable.control('', [Validators.required]),
|
||||
},
|
||||
{ validators: passwordMatchValidator },
|
||||
);
|
||||
|
||||
readonly submitting = signal(false);
|
||||
readonly serverError = signal<string | null>(null);
|
||||
readonly serverErrorCode = signal<string | null>(null);
|
||||
|
||||
readonly canRetry = computed(
|
||||
() => !this.submitting() && this.serverError() !== null && this.serverErrorCode() !== 'USERNAME_TAKEN',
|
||||
);
|
||||
|
||||
readonly usernameErrors = computed(() => {
|
||||
const c = this.form.controls.username;
|
||||
if (!c.touched || c.valid) return null;
|
||||
if (c.errors?.['required']) return 'Username is required';
|
||||
if (c.errors?.['minlength']) return 'Username must be at least 3 characters';
|
||||
if (c.errors?.['maxlength']) return 'Username must be at most 32 characters';
|
||||
if (c.errors?.['pattern']) return 'Username may only contain letters, digits, dot, dash and underscore';
|
||||
return 'Invalid username';
|
||||
});
|
||||
|
||||
readonly confirmErrors = computed(() => {
|
||||
const c = this.form.controls.passwordConfirm;
|
||||
const groupMismatch = this.form.errors?.['passwordMismatch'];
|
||||
if ((!c.touched || c.valid) && !groupMismatch) return null;
|
||||
if (c.errors?.['required']) return 'Please confirm your password';
|
||||
if (groupMismatch) return 'Passwords do not match';
|
||||
return 'Invalid value';
|
||||
});
|
||||
|
||||
@HostListener('document:keydown.escape', ['$event'])
|
||||
swallowEscape(event: KeyboardEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
swallowBackdrop(_event: MouseEvent): void {
|
||||
// intentionally no-op: modal cannot be dismissed by clicking the backdrop
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
if (this.submitting()) return;
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.submitting.set(true);
|
||||
this.serverError.set(null);
|
||||
this.serverErrorCode.set(null);
|
||||
|
||||
const { username, password, passwordConfirm } = this.form.getRawValue();
|
||||
const result = await this.api.create({ username, password, passwordConfirm });
|
||||
|
||||
this.submitting.set(false);
|
||||
|
||||
if (result.ok) {
|
||||
this.auth.setSession(result.accessToken, result.user);
|
||||
this.bootstrap.markInitialized();
|
||||
await this.router.navigateByUrl('/challenges');
|
||||
return;
|
||||
}
|
||||
|
||||
const failure = result as CreateAdminFailure;
|
||||
if (failure.code === 'USERNAME_TAKEN') {
|
||||
this.serverError.set('Username already exists');
|
||||
this.serverErrorCode.set('USERNAME_TAKEN');
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverError.set('Setup failed, please retry');
|
||||
this.serverErrorCode.set(failure.code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface CreateAdminRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
}
|
||||
|
||||
export interface CreateAdminSuccess {
|
||||
ok: true;
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
user: { id: string; username: string; role: 'admin' | 'player' };
|
||||
}
|
||||
|
||||
export interface CreateAdminFailure {
|
||||
ok: false;
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type CreateAdminResult = CreateAdminSuccess | CreateAdminFailure;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SetupCreateAdminService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
async ensureCsrf(): Promise<void> {
|
||||
try {
|
||||
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
|
||||
} catch {
|
||||
// ignore: csrf middleware will mint a cookie if missing
|
||||
}
|
||||
}
|
||||
|
||||
async create(body: CreateAdminRequest): Promise<CreateAdminResult> {
|
||||
await this.ensureCsrf();
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<{
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
user: { id: string; username: string; role: 'admin' | 'player' };
|
||||
}>('/api/v1/setup/create-admin', body, { withCredentials: true, observe: 'response' }),
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
accessToken: res.body!.accessToken,
|
||||
expiresIn: res.body!.expiresIn,
|
||||
user: res.body!.user,
|
||||
};
|
||||
} catch (e) {
|
||||
return mapError(e as HttpErrorResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapError(err: HttpErrorResponse): CreateAdminFailure {
|
||||
const body = err.error as { code?: string; message?: string } | null;
|
||||
const code = body?.code ?? 'INTERNAL';
|
||||
const message = body?.message ?? err.message ?? 'Setup failed';
|
||||
return { ok: false, status: err.status ?? 0, code, message };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface PasswordMatchGroupLike {
|
||||
get(name: 'password' | 'passwordConfirm'): { value: string | null | undefined } | null;
|
||||
}
|
||||
|
||||
export interface PasswordMatchResult {
|
||||
passwordMismatch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group-level validator for the "Confirm Password" field.
|
||||
* Returns `{ passwordMismatch: true }` when the two values differ,
|
||||
* or `null` when they match (or when either is empty, so that
|
||||
* `required`/`minlength` validators can take over).
|
||||
*/
|
||||
export function passwordMatchValidator(group: PasswordMatchGroupLike): PasswordMatchResult | null {
|
||||
const password = group.get('password')?.value;
|
||||
const confirm = group.get('passwordConfirm')?.value;
|
||||
if (!password || !confirm) return null;
|
||||
return password === confirm ? null : { passwordMismatch: true };
|
||||
}
|
||||
Generated
+30
-20
@@ -39,6 +39,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"helmet": "^7.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -52,6 +53,7 @@
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^9.0.8",
|
||||
@@ -2656,7 +2658,7 @@
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
@@ -2669,7 +2671,7 @@
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
@@ -3739,7 +3741,7 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -3760,7 +3762,7 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -5148,28 +5150,28 @@
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tufjs/canonical-json": {
|
||||
@@ -5523,6 +5525,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "1.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
|
||||
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -5965,7 +5977,7 @@
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -5999,7 +6011,7 @@
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
@@ -6227,7 +6239,7 @@
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argon2": {
|
||||
@@ -7618,7 +7630,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/critters": {
|
||||
@@ -8024,7 +8036,7 @@
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
@@ -8254,7 +8266,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -8265,7 +8276,6 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -11838,7 +11848,7 @@
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/make-fetch-happen": {
|
||||
@@ -15988,7 +15998,7 @@
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
@@ -16367,7 +16377,7 @@
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -16602,7 +16612,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
@@ -17799,7 +17809,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
||||
@@ -2,7 +2,11 @@ import request from 'supertest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
|
||||
const csrfSkipPaths = new Set<string>(['/api/v1/auth/login', '/api/v1/auth/register-first-admin']);
|
||||
const csrfSkipPaths = new Set<string>([
|
||||
'/api/v1/auth/login',
|
||||
'/api/v1/auth/register-first-admin',
|
||||
'/api/v1/setup/create-admin',
|
||||
]);
|
||||
|
||||
function attachCsrfHeader(test: request.Test, method: string, path: string, agent: any): request.Test {
|
||||
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase())) return test;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import { Repository } from 'typeorm';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { csrfClient } from './csrf-client';
|
||||
import { initDb } from './db-helper';
|
||||
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
||||
import { SetupService } from '../../backend/src/modules/setup/setup.service';
|
||||
|
||||
describe('Setup: POST /api/v1/setup/create-admin', () => {
|
||||
let app: INestApplication;
|
||||
let users: Repository<UserEntity>;
|
||||
let setupService: SetupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
users = app.get<Repository<UserEntity>>(getRepositoryToken(UserEntity));
|
||||
setupService = app.get(SetupService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.clear();
|
||||
});
|
||||
|
||||
it('creates the very first admin and exposes a session + cookie', async () => {
|
||||
const c = csrfClient(app);
|
||||
const res = await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'first.admin', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.accessToken).toEqual(expect.any(String));
|
||||
expect(res.body.user.role).toBe('admin');
|
||||
expect(res.body.user.username).toBe('first.admin');
|
||||
|
||||
const setCookieRaw = res.headers['set-cookie'] as unknown;
|
||||
const setCookie: string[] = Array.isArray(setCookieRaw) ? setCookieRaw : setCookieRaw ? [String(setCookieRaw)] : [];
|
||||
expect(setCookie.some((h) => String(h).startsWith('rt='))).toBe(true);
|
||||
|
||||
const adminCount = await users.count({ where: { role: 'admin' } });
|
||||
expect(adminCount).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 404 once an admin already exists (route is hidden)', async () => {
|
||||
const c = csrfClient(app);
|
||||
await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'first', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
|
||||
const res = await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'second', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toBeDefined();
|
||||
});
|
||||
|
||||
it('SetupService would map a username collision to USERNAME_TAKEN (reached only during a race before init)', () => {
|
||||
// The current ordering (route hidden after init) means USERNAME_TAKEN is
|
||||
// reachable only when two requests enter the transaction concurrently
|
||||
// before any admin exists. Verify the error code is present in the
|
||||
// error-codes enum so the frontend can switch on it.
|
||||
const { ERROR_CODES } = require('../../backend/src/common/errors/error-codes');
|
||||
expect(ERROR_CODES.USERNAME_TAKEN).toBe('USERNAME_TAKEN');
|
||||
});
|
||||
|
||||
it('SetupService throws not-found (route hidden) after init', async () => {
|
||||
await setupService.createAdmin('init', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1');
|
||||
await expect(
|
||||
setupService.createAdmin('newone', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1'),
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('rejects weak password with WEAK_PASSWORD', async () => {
|
||||
const c = csrfClient(app);
|
||||
const res = await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'weakpw', password: 'short', passwordConfirm: 'short' })
|
||||
.expect(400);
|
||||
expect(res.body.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('rejects passwordConfirm mismatch with VALIDATION_FAILED', async () => {
|
||||
const c = csrfClient(app);
|
||||
await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'mismatch', password: 'Sup3rSecret!Pass', passwordConfirm: 'Different!Pass1' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects invalid username characters with VALIDATION_FAILED', async () => {
|
||||
const c = csrfClient(app);
|
||||
await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'bad name!', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
passwordMatchValidator,
|
||||
PasswordMatchGroupLike,
|
||||
} from '../../frontend/src/app/features/setup/setup-create-admin.validators';
|
||||
|
||||
function makeGroup(password: string | null | undefined, confirm: string | null | undefined): PasswordMatchGroupLike {
|
||||
return {
|
||||
get: (name) => {
|
||||
if (name === 'password') return { value: password };
|
||||
if (name === 'passwordConfirm') return { value: confirm };
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('passwordMatchValidator', () => {
|
||||
it('returns null when either field is empty (let other validators handle required)', () => {
|
||||
expect(passwordMatchValidator(makeGroup('', 'x'))).toBeNull();
|
||||
expect(passwordMatchValidator(makeGroup('x', ''))).toBeNull();
|
||||
expect(passwordMatchValidator(makeGroup(undefined, undefined))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when passwords match', () => {
|
||||
expect(passwordMatchValidator(makeGroup('Sup3r!', 'Sup3r!'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns { passwordMismatch: true } when passwords differ', () => {
|
||||
expect(passwordMatchValidator(makeGroup('Sup3r!', 'Sup4r!'))).toEqual({
|
||||
passwordMismatch: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Username field rules (mirroring component validators)', () => {
|
||||
const PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
const MIN = 3;
|
||||
const MAX = 32;
|
||||
|
||||
function isValidUsername(value: string): boolean {
|
||||
return value.length >= MIN && value.length <= MAX && PATTERN.test(value);
|
||||
}
|
||||
|
||||
it('accepts a 3-32 char username with allowed characters', () => {
|
||||
expect(isValidUsername('first.admin_01')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects usernames shorter than 3', () => {
|
||||
expect(isValidUsername('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects usernames with disallowed characters', () => {
|
||||
expect(isValidUsername('bad name')).toBe(false);
|
||||
expect(isValidUsername('user!')).toBe(false);
|
||||
expect(isValidUsername('user@')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects usernames longer than 32', () => {
|
||||
expect(isValidUsername('a'.repeat(33))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user