feat: bootstrap from Fabric monorepo
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Server
|
||||||
|
PORT=7001
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
DB_HOST=mysql-center
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=fabric
|
||||||
|
DB_PASSWORD=fabric
|
||||||
|
DB_NAME=fabric_center
|
||||||
|
DB_SYNC=true
|
||||||
|
DB_LOGGING=false
|
||||||
|
|
||||||
|
# Auth (to be used in auth module)
|
||||||
|
JWT_ACCESS_SECRET=change-me-access
|
||||||
|
JWT_REFRESH_SECRET=change-me-refresh
|
||||||
|
JWT_ACCESS_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
# Center <-> Guild handshake
|
||||||
|
CENTER_SHARED_SECRET=change-me-center-secret
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
EXPOSE 7001
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
14
README.md
Normal file
14
README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Fabric.Backend.Center
|
||||||
|
|
||||||
|
Identity Hub service for Fabric.
|
||||||
|
|
||||||
|
## Scope (MVP)
|
||||||
|
- User register/login
|
||||||
|
- Session/token management
|
||||||
|
- Guild Node registration + shared-secret handshake
|
||||||
|
- Center-level audit logs
|
||||||
|
|
||||||
|
## Next
|
||||||
|
- API skeleton (NestJS)
|
||||||
|
- Auth module
|
||||||
|
- Guild node registry module
|
||||||
28
eslint.config.mjs
Normal file
28
eslint.config.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tsPlugin.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
6098
package-lock.json
generated
Normal file
6098
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "fabric-backend-center",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Fabric Identity Hub (Center service)",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "ts-node src/main.ts",
|
||||||
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
|
"format": "prettier --write 'src/**/*.ts'",
|
||||||
|
"test:unit": "vitest run src/**/*.spec.ts --exclude src/*.integration.spec.ts --exclude dist/**",
|
||||||
|
"test:integration": "vitest run src/*.integration.spec.ts --exclude dist/**",
|
||||||
|
"migration:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate src/migrations/AutoMigration -d src/data-source.ts",
|
||||||
|
"migration:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d src/data-source.ts",
|
||||||
|
"migration:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d src/data-source.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.4.8",
|
||||||
|
"@nestjs/core": "^10.4.8",
|
||||||
|
"@nestjs/platform-express": "^10.4.8",
|
||||||
|
"@nestjs/swagger": "^7.4.2",
|
||||||
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mysql2": "^3.22.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"typeorm": "^0.3.29"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@nestjs/testing": "^10.4.22",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||||
|
"@typescript-eslint/parser": "^8.59.3",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app.module.ts
Normal file
21
src/app.module.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { buildTypeOrmConfig } from './database.config';
|
||||||
|
import { HealthController } from './common/health.controller';
|
||||||
|
import { MetricsController } from './common/metrics.controller';
|
||||||
|
import { MetricsService } from './common/metrics.service';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forRoot(buildTypeOrmConfig()),
|
||||||
|
AuditModule,
|
||||||
|
AuthModule,
|
||||||
|
NodesModule,
|
||||||
|
],
|
||||||
|
controllers: [HealthController, MetricsController],
|
||||||
|
providers: [MetricsService],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
12
src/audit/audit.module.ts
Normal file
12
src/audit/audit.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditLog } from '../entities/audit-log.entity';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||||
|
providers: [AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
31
src/audit/audit.service.ts
Normal file
31
src/audit/audit.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLog } from '../entities/audit-log.entity';
|
||||||
|
|
||||||
|
export type AuditWriteInput = {
|
||||||
|
action: string;
|
||||||
|
actorId?: string | null;
|
||||||
|
targetType?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AuditLog)
|
||||||
|
private readonly auditRepo: Repository<AuditLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async write(input: AuditWriteInput): Promise<void> {
|
||||||
|
const row = this.auditRepo.create({
|
||||||
|
action: input.action,
|
||||||
|
actorId: input.actorId ?? null,
|
||||||
|
targetType: input.targetType ?? null,
|
||||||
|
targetId: input.targetId ?? null,
|
||||||
|
detail: input.detail ?? null,
|
||||||
|
});
|
||||||
|
await this.auditRepo.save(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/auth/auth.controller.ts
Normal file
31
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { RegisterDto } from './dto.register.dto';
|
||||||
|
import { LoginDto } from './dto.login.dto';
|
||||||
|
import { RefreshDto } from './dto.refresh.dto';
|
||||||
|
import { LogoutDto } from './dto.logout.dto';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
register(@Body() body: RegisterDto) {
|
||||||
|
return this.authService.register(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() body: LoginDto) {
|
||||||
|
return this.authService.login(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
refresh(@Body() body: RefreshDto) {
|
||||||
|
return this.authService.refresh(body.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
logout(@Body() body: LogoutDto) {
|
||||||
|
return this.authService.logout(body.refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/auth/auth.module.ts
Normal file
12
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { User } from '../entities/user.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
158
src/auth/auth.service.ts
Normal file
158
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import { User } from '../entities/user.entity';
|
||||||
|
import { RegisterDto } from './dto.register.dto';
|
||||||
|
import { LoginDto } from './dto.login.dto';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { parseDurationToSeconds } from './token.util';
|
||||||
|
|
||||||
|
function signAccessToken(userId: string, email: string): string {
|
||||||
|
const secret = process.env.JWT_ACCESS_SECRET as string;
|
||||||
|
const expiresIn = parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m', 900);
|
||||||
|
return jwt.sign({ sub: userId, email }, secret, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
function signRefreshToken(userId: string, email: string): string {
|
||||||
|
const secret = process.env.JWT_REFRESH_SECRET as string;
|
||||||
|
const expiresIn = parseDurationToSeconds(process.env.JWT_REFRESH_EXPIRES_IN ?? '30d', 2592000);
|
||||||
|
return jwt.sign({ sub: userId, email, typ: 'refresh' }, secret, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepo: Repository<User>,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(input: RegisterDto) {
|
||||||
|
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
||||||
|
if (exists) {
|
||||||
|
throw new ConflictException('email already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(input.password, 10);
|
||||||
|
const user = this.userRepo.create({
|
||||||
|
email: input.email,
|
||||||
|
passwordHash,
|
||||||
|
refreshTokenHash: null,
|
||||||
|
});
|
||||||
|
const saved = await this.userRepo.save(user);
|
||||||
|
|
||||||
|
await this.audit.write({
|
||||||
|
action: 'auth.register',
|
||||||
|
actorId: saved.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: saved.id,
|
||||||
|
detail: JSON.stringify({ email: saved.email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: saved.id,
|
||||||
|
email: saved.email,
|
||||||
|
createdAt: saved.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(input: LoginDto) {
|
||||||
|
const user = await this.userRepo.findOne({ where: { email: input.email } });
|
||||||
|
if (!user) throw new UnauthorizedException('invalid credentials');
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(input.password, user.passwordHash);
|
||||||
|
if (!ok) throw new UnauthorizedException('invalid credentials');
|
||||||
|
|
||||||
|
const accessToken = signAccessToken(user.id, user.email);
|
||||||
|
const refreshToken = signRefreshToken(user.id, user.email);
|
||||||
|
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
|
||||||
|
await this.audit.write({
|
||||||
|
action: 'auth.login',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
detail: JSON.stringify({ email: user.email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(refreshToken: string) {
|
||||||
|
let payload: jwt.JwtPayload;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = String(payload.sub ?? '');
|
||||||
|
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||||
|
if (!user || !user.refreshTokenHash) {
|
||||||
|
throw new UnauthorizedException('invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenOk = await bcrypt.compare(refreshToken, user.refreshTokenHash);
|
||||||
|
if (!tokenOk) throw new UnauthorizedException('invalid refresh token');
|
||||||
|
|
||||||
|
const newAccessToken = signAccessToken(user.id, user.email);
|
||||||
|
const newRefreshToken = signRefreshToken(user.id, user.email);
|
||||||
|
user.refreshTokenHash = await bcrypt.hash(newRefreshToken, 10);
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
|
||||||
|
await this.audit.write({
|
||||||
|
action: 'auth.refresh',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
detail: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: newAccessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(refreshToken: string) {
|
||||||
|
let payload: jwt.JwtPayload;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
|
||||||
|
} catch {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = String(payload.sub ?? '');
|
||||||
|
if (!userId) return { status: 'ok' };
|
||||||
|
|
||||||
|
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||||
|
if (!user) return { status: 'ok' };
|
||||||
|
|
||||||
|
user.refreshTokenHash = null;
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
await this.audit.write({
|
||||||
|
action: 'auth.logout',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
detail: null,
|
||||||
|
});
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/auth/dto.login.dto.ts
Normal file
10
src/auth/dto.login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
7
src/auth/dto.logout.dto.ts
Normal file
7
src/auth/dto.logout.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LogoutDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(16)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
7
src/auth/dto.refresh.dto.ts
Normal file
7
src/auth/dto.refresh.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RefreshDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(16)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
10
src/auth/dto.register.dto.ts
Normal file
10
src/auth/dto.register.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
14
src/auth/token.util.spec.ts
Normal file
14
src/auth/token.util.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseDurationToSeconds } from './token.util';
|
||||||
|
|
||||||
|
describe('parseDurationToSeconds', () => {
|
||||||
|
it('parses time units', () => {
|
||||||
|
expect(parseDurationToSeconds('15m', 1)).toBe(900);
|
||||||
|
expect(parseDurationToSeconds('2h', 1)).toBe(7200);
|
||||||
|
expect(parseDurationToSeconds('10', 1)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back on invalid input', () => {
|
||||||
|
expect(parseDurationToSeconds('abc', 42)).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
src/auth/token.util.ts
Normal file
15
src/auth/token.util.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function parseDurationToSeconds(input: string, fallbackSeconds: number): number {
|
||||||
|
const raw = input.trim();
|
||||||
|
if (/^\d+$/.test(raw)) return Number(raw);
|
||||||
|
|
||||||
|
const m = raw.match(/^(\d+)([smhd])$/i);
|
||||||
|
if (!m) return fallbackSeconds;
|
||||||
|
|
||||||
|
const value = Number(m[1]);
|
||||||
|
const unit = m[2].toLowerCase();
|
||||||
|
if (unit === 's') return value;
|
||||||
|
if (unit === 'm') return value * 60;
|
||||||
|
if (unit === 'h') return value * 3600;
|
||||||
|
if (unit === 'd') return value * 86400;
|
||||||
|
return fallbackSeconds;
|
||||||
|
}
|
||||||
25
src/common/health.controller.ts
Normal file
25
src/common/health.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
@Controller('healthz')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async get() {
|
||||||
|
try {
|
||||||
|
await this.dataSource.query('SELECT 1');
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
service: 'center',
|
||||||
|
database: 'ready',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException({
|
||||||
|
ok: false,
|
||||||
|
service: 'center',
|
||||||
|
database: 'not_ready',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/common/hmac.ts
Normal file
38
src/common/hmac.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
|
export type HmacInput = {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLOCK_SKEW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export function buildCanonical(input: HmacInput): string {
|
||||||
|
return [
|
||||||
|
input.method.toUpperCase(),
|
||||||
|
input.path,
|
||||||
|
input.timestamp,
|
||||||
|
input.nonce,
|
||||||
|
input.body,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signCanonical(secret: string, canonical: string): string {
|
||||||
|
return createHmac('sha256', secret).update(canonical).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyRequestTime(timestamp: string): boolean {
|
||||||
|
const ts = Date.parse(timestamp);
|
||||||
|
if (Number.isNaN(ts)) return false;
|
||||||
|
return Math.abs(Date.now() - ts) <= CLOCK_SKEW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeEqualHex(a: string, b: string): boolean {
|
||||||
|
const aa = Buffer.from(a, 'hex');
|
||||||
|
const bb = Buffer.from(b, 'hex');
|
||||||
|
if (aa.length !== bb.length) return false;
|
||||||
|
return timingSafeEqual(aa, bb);
|
||||||
|
}
|
||||||
12
src/common/metrics.controller.ts
Normal file
12
src/common/metrics.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
@Controller('metrics')
|
||||||
|
export class MetricsController {
|
||||||
|
constructor(private readonly metrics: MetricsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
get() {
|
||||||
|
return this.metrics.snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/common/metrics.service.ts
Normal file
35
src/common/metrics.service.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
requests: number;
|
||||||
|
errors: number;
|
||||||
|
totalDurationMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsService {
|
||||||
|
private readonly bucket: Bucket = { requests: 0, errors: 0, totalDurationMs: 0 };
|
||||||
|
private startedAt = Date.now();
|
||||||
|
|
||||||
|
record(statusCode: number, durationMs: number): void {
|
||||||
|
this.bucket.requests += 1;
|
||||||
|
if (statusCode >= 400) this.bucket.errors += 1;
|
||||||
|
this.bucket.totalDurationMs += durationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
const uptimeSec = Math.max(1, Math.floor((Date.now() - this.startedAt) / 1000));
|
||||||
|
const qps = this.bucket.requests / uptimeSec;
|
||||||
|
const avgLatencyMs = this.bucket.requests > 0 ? this.bucket.totalDurationMs / this.bucket.requests : 0;
|
||||||
|
const errorRate = this.bucket.requests > 0 ? this.bucket.errors / this.bucket.requests : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
requests: this.bucket.requests,
|
||||||
|
errors: this.bucket.errors,
|
||||||
|
qps,
|
||||||
|
avgLatencyMs,
|
||||||
|
errorRate,
|
||||||
|
uptimeSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/common/request-context.middleware.ts
Normal file
36
src/common/request-context.middleware.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
type ReqWithId = Request & { requestId?: string };
|
||||||
|
|
||||||
|
export function createRequestContextMiddleware(service: 'center' | 'guild', metrics: MetricsService) {
|
||||||
|
return (req: ReqWithId, res: Response, next: NextFunction): void => {
|
||||||
|
const headerId = req.headers['x-request-id'];
|
||||||
|
const requestId =
|
||||||
|
(Array.isArray(headerId) ? headerId[0] : headerId) || randomUUID();
|
||||||
|
|
||||||
|
req.requestId = requestId;
|
||||||
|
res.setHeader('x-request-id', requestId);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
metrics.record(res.statusCode, durationMs);
|
||||||
|
|
||||||
|
const log = {
|
||||||
|
level: 'info',
|
||||||
|
service,
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
durationMs,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(log));
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
7
src/common/version.ts
Normal file
7
src/common/version.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const FABRIC_PROTOCOL_VERSION = '1';
|
||||||
|
|
||||||
|
export function normalizeVersion(input?: string): string {
|
||||||
|
if (!input) return FABRIC_PROTOCOL_VERSION;
|
||||||
|
const v = input.trim();
|
||||||
|
return v;
|
||||||
|
}
|
||||||
16
src/data-source.ts
Normal file
16
src/data-source.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||||
|
import { buildTypeOrmConfig } from './database.config';
|
||||||
|
|
||||||
|
const cfg = buildTypeOrmConfig();
|
||||||
|
|
||||||
|
const options: DataSourceOptions = {
|
||||||
|
...(cfg as Record<string, unknown>),
|
||||||
|
type: 'mysql',
|
||||||
|
migrations: ['src/migrations/*.{ts,js}'],
|
||||||
|
synchronize: false,
|
||||||
|
} as DataSourceOptions;
|
||||||
|
|
||||||
|
export const AppDataSource = new DataSource(options);
|
||||||
|
|
||||||
|
export default AppDataSource;
|
||||||
16
src/database.config.ts
Normal file
16
src/database.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
|
import { GuildNode } from './entities/guild-node.entity';
|
||||||
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
|
|
||||||
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
|
type: 'mysql',
|
||||||
|
host: process.env.DB_HOST ?? 'mysql-center',
|
||||||
|
port: Number(process.env.DB_PORT ?? 3306),
|
||||||
|
username: process.env.DB_USER ?? 'fabric',
|
||||||
|
password: process.env.DB_PASSWORD ?? 'fabric',
|
||||||
|
database: process.env.DB_NAME ?? 'fabric_center',
|
||||||
|
entities: [User, GuildNode, AuditLog],
|
||||||
|
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
||||||
|
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
||||||
|
});
|
||||||
25
src/entities/audit-log.entity.ts
Normal file
25
src/entities/audit-log.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('audit_logs')
|
||||||
|
export class AuditLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
action!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
actorId!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
targetType!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||||
|
targetId!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
detail!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
38
src/entities/guild-node.entity.ts
Normal file
38
src/entities/guild-node.entity.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('guild_nodes')
|
||||||
|
export class GuildNode {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
nodeId!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
endpoint!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['active', 'offline', 'revoked'],
|
||||||
|
default: 'active',
|
||||||
|
})
|
||||||
|
status!: 'active' | 'offline' | 'revoked';
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
lastHeartbeatAt!: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
19
src/entities/user.entity.ts
Normal file
19
src/entities/user.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
passwordHash!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
refreshTokenHash!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
69
src/health.integration.spec.ts
Normal file
69
src/health.integration.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { createHmac, randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1';
|
||||||
|
process.env.DB_PORT = '3307';
|
||||||
|
process.env.DB_USER = 'fabric';
|
||||||
|
process.env.DB_PASSWORD = 'fabric';
|
||||||
|
process.env.DB_NAME = 'fabric_center';
|
||||||
|
process.env.DB_SYNC = 'true';
|
||||||
|
process.env.CENTER_SHARED_SECRET = 'test-center-secret';
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'test-access-secret';
|
||||||
|
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
|
||||||
|
|
||||||
|
describe('center integration (mysql + api)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { AppModule } = await import('./app.module');
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
await app.init();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/healthz returns db ready', async () => {
|
||||||
|
const res = await request(app.getHttpServer()).get('/api/healthz');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.database).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/nodes/register follows center-guild contract (version + hmac)', async () => {
|
||||||
|
const body = {
|
||||||
|
nodeId: `guild-node-${Date.now()}`,
|
||||||
|
name: 'Guild Node Contract Test',
|
||||||
|
endpoint: `http://guild-${Date.now()}:7002`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const nonce = randomUUID();
|
||||||
|
const canonical = ['POST', '/api/nodes/register', timestamp, nonce, JSON.stringify(body)].join('\n');
|
||||||
|
const signature = createHmac('sha256', process.env.CENTER_SHARED_SECRET as string)
|
||||||
|
.update(canonical)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/nodes/register')
|
||||||
|
.set('x-fabric-version', '1')
|
||||||
|
.set('x-fabric-timestamp', timestamp)
|
||||||
|
.set('x-fabric-nonce', nonce)
|
||||||
|
.set('x-fabric-signature', signature)
|
||||||
|
.send(body);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.status).toBe('accepted');
|
||||||
|
expect(res.body.negotiatedVersion).toBe('1');
|
||||||
|
expect(res.body.node.nodeId).toBe(body.nodeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/main.ts
Normal file
56
src/main.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { createRequestContextMiddleware } from './common/request-context.middleware';
|
||||||
|
import { MetricsService } from './common/metrics.service';
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
throw new Error(`Missing required env: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEnv(): void {
|
||||||
|
requireEnv('DB_HOST');
|
||||||
|
requireEnv('DB_PORT');
|
||||||
|
requireEnv('DB_USER');
|
||||||
|
requireEnv('DB_PASSWORD');
|
||||||
|
requireEnv('DB_NAME');
|
||||||
|
requireEnv('CENTER_SHARED_SECRET');
|
||||||
|
requireEnv('JWT_ACCESS_SECRET');
|
||||||
|
requireEnv('JWT_REFRESH_SECRET');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
validateEnv();
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
const metrics = app.get(MetricsService);
|
||||||
|
app.use(createRequestContextMiddleware('center', metrics));
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('Fabric Backend Center API')
|
||||||
|
.setDescription('Identity Hub APIs for Fabric')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.build();
|
||||||
|
const swaggerDoc = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('docs', app, swaggerDoc);
|
||||||
|
|
||||||
|
const port = process.env.PORT ? Number(process.env.PORT) : 7001;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Fabric.Backend.Center listening on :${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
0
src/migrations/.gitkeep
Normal file
0
src/migrations/.gitkeep
Normal file
14
src/nodes/dto.register-node.dto.ts
Normal file
14
src/nodes/dto.register-node.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, IsUrl, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterNodeDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
nodeId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsUrl({ require_tld: false })
|
||||||
|
endpoint!: string;
|
||||||
|
}
|
||||||
6
src/nodes/dto.update-node-status.dto.ts
Normal file
6
src/nodes/dto.update-node-status.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsIn } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateNodeStatusDto {
|
||||||
|
@IsIn(['active', 'offline', 'revoked'])
|
||||||
|
status!: 'active' | 'offline' | 'revoked';
|
||||||
|
}
|
||||||
196
src/nodes/nodes.controller.ts
Normal file
196
src/nodes/nodes.controller.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
ConflictException,
|
||||||
|
Controller,
|
||||||
|
DefaultValuePipe,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpException,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { RegisterNodeDto } from './dto.register-node.dto';
|
||||||
|
import { UpdateNodeStatusDto } from './dto.update-node-status.dto';
|
||||||
|
import {
|
||||||
|
buildCanonical,
|
||||||
|
safeEqualHex,
|
||||||
|
signCanonical,
|
||||||
|
verifyRequestTime,
|
||||||
|
} from '../common/hmac';
|
||||||
|
import { FABRIC_PROTOCOL_VERSION, normalizeVersion } from '../common/version';
|
||||||
|
|
||||||
|
@Controller('nodes')
|
||||||
|
export class NodesController {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(GuildNode)
|
||||||
|
private readonly nodeRepo: Repository<GuildNode>,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
async register(
|
||||||
|
@Body() body: RegisterNodeDto,
|
||||||
|
@Headers('x-fabric-signature') signature?: string,
|
||||||
|
@Headers('x-fabric-timestamp') timestamp?: string,
|
||||||
|
@Headers('x-fabric-nonce') nonce?: string,
|
||||||
|
@Headers('x-fabric-version') fabricVersion?: string,
|
||||||
|
) {
|
||||||
|
const requestedVersion = normalizeVersion(fabricVersion);
|
||||||
|
if (requestedVersion !== FABRIC_PROTOCOL_VERSION) {
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: 'FABRIC_VERSION_NOT_SUPPORTED',
|
||||||
|
message: `unsupported protocol version: ${requestedVersion}`,
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
supportedVersion: FABRIC_PROTOCOL_VERSION,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = process.env.CENTER_SHARED_SECRET as string;
|
||||||
|
if (!signature || !timestamp || !nonce || !verifyRequestTime(timestamp)) {
|
||||||
|
throw new ForbiddenException('invalid hmac headers');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = buildCanonical({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/nodes/register',
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const expected = signCanonical(secret, canonical);
|
||||||
|
if (!safeEqualHex(signature, expected)) {
|
||||||
|
throw new ForbiddenException('invalid shared secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existedByNodeId = await this.nodeRepo.findOne({
|
||||||
|
where: { nodeId: body.nodeId },
|
||||||
|
});
|
||||||
|
if (existedByNodeId) {
|
||||||
|
throw new ConflictException('nodeId already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existedByEndpoint = await this.nodeRepo.findOne({
|
||||||
|
where: { endpoint: body.endpoint },
|
||||||
|
});
|
||||||
|
if (existedByEndpoint) {
|
||||||
|
throw new ConflictException('endpoint already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this.nodeRepo.create({
|
||||||
|
nodeId: body.nodeId,
|
||||||
|
name: body.name,
|
||||||
|
endpoint: body.endpoint,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
const saved = await this.nodeRepo.save(node);
|
||||||
|
await this.audit.write({
|
||||||
|
action: 'node.register',
|
||||||
|
targetType: 'node',
|
||||||
|
targetId: saved.nodeId,
|
||||||
|
detail: JSON.stringify({ endpoint: saved.endpoint }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'accepted',
|
||||||
|
negotiatedVersion: FABRIC_PROTOCOL_VERSION,
|
||||||
|
node: {
|
||||||
|
id: saved.id,
|
||||||
|
nodeId: saved.nodeId,
|
||||||
|
name: saved.name,
|
||||||
|
endpoint: saved.endpoint,
|
||||||
|
status: saved.status,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':nodeId/heartbeat')
|
||||||
|
async heartbeat(@Param('nodeId') nodeId: string) {
|
||||||
|
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
||||||
|
if (!node) {
|
||||||
|
throw new NotFoundException('node not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
node.lastHeartbeatAt = new Date();
|
||||||
|
if (node.status !== 'revoked') {
|
||||||
|
node.status = 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await this.nodeRepo.save(node);
|
||||||
|
await this.audit.write({
|
||||||
|
action: 'node.heartbeat',
|
||||||
|
targetType: 'node',
|
||||||
|
targetId: saved.nodeId,
|
||||||
|
detail: JSON.stringify({ status: saved.status }),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
nodeId: saved.nodeId,
|
||||||
|
nodeStatus: saved.status,
|
||||||
|
lastHeartbeatAt: saved.lastHeartbeatAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':nodeId/status')
|
||||||
|
async updateStatus(
|
||||||
|
@Param('nodeId') nodeId: string,
|
||||||
|
@Body() body: UpdateNodeStatusDto,
|
||||||
|
) {
|
||||||
|
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
||||||
|
if (!node) {
|
||||||
|
throw new NotFoundException('node not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
node.status = body.status;
|
||||||
|
const saved = await this.nodeRepo.save(node);
|
||||||
|
await this.audit.write({
|
||||||
|
action: 'node.status.update',
|
||||||
|
targetType: 'node',
|
||||||
|
targetId: saved.nodeId,
|
||||||
|
detail: JSON.stringify({ status: saved.status }),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: saved.id,
|
||||||
|
nodeId: saved.nodeId,
|
||||||
|
name: saved.name,
|
||||||
|
endpoint: saved.endpoint,
|
||||||
|
status: saved.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(
|
||||||
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
|
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
|
||||||
|
) {
|
||||||
|
const safePage = page < 1 ? 1 : page;
|
||||||
|
const safePageSize = pageSize < 1 ? 20 : Math.min(pageSize, 100);
|
||||||
|
|
||||||
|
const [items, total] = await this.nodeRepo.findAndCount({
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
skip: (safePage - 1) * safePageSize,
|
||||||
|
take: safePageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page: safePage,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / safePageSize)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/nodes/nodes.module.ts
Normal file
10
src/nodes/nodes.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { NodesController } from './nodes.controller';
|
||||||
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([GuildNode])],
|
||||||
|
controllers: [NodesController],
|
||||||
|
})
|
||||||
|
export class NodesModule {}
|
||||||
7
tsconfig.build.json
Normal file
7
tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2020",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.spec.ts', 'src/*.integration.spec.ts'],
|
||||||
|
exclude: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user