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;
|
||||
|
||||
@@ -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