chore(submodule): convert backend center/guild to dedicated repos
Some checks failed
backend-ci / verify (Fabric.Backend.Center) (push) Has been cancelled
backend-ci / verify (Fabric.Backend.Guild) (push) Has been cancelled

This commit is contained in:
nav
2026-05-13 07:06:58 +00:00
parent 38eb704df3
commit 358064b810
88 changed files with 2 additions and 15018 deletions

1
Fabric.Backend.Center Submodule

Submodule Fabric.Backend.Center added at 03a3342d2a

View File

@@ -1,20 +0,0 @@
# 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

View File

@@ -1,2 +0,0 @@
node_modules/
dist/

View File

@@ -1,19 +0,0 @@
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"]

View File

@@ -1,14 +0,0 @@
# 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

View File

@@ -1,28 +0,0 @@
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,
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +0,0 @@
{
"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"
}
}

View File

@@ -1,21 +0,0 @@
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 {}

View File

@@ -1,12 +0,0 @@
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 {}

View File

@@ -1,31 +0,0 @@
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);
}
}

View File

@@ -1,31 +0,0 @@
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);
}
}

View File

@@ -1,12 +0,0 @@
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 {}

View File

@@ -1,158 +0,0 @@
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' };
}
}

View File

@@ -1,10 +0,0 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}

View File

@@ -1,7 +0,0 @@
import { IsString, MinLength } from 'class-validator';
export class LogoutDto {
@IsString()
@MinLength(16)
refreshToken!: string;
}

View File

@@ -1,7 +0,0 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshDto {
@IsString()
@MinLength(16)
refreshToken!: string;
}

View File

@@ -1,10 +0,0 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class RegisterDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}

View File

@@ -1,14 +0,0 @@
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);
});
});

View File

@@ -1,15 +0,0 @@
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;
}

View File

@@ -1,25 +0,0 @@
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',
});
}
}
}

View File

@@ -1,38 +0,0 @@
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);
}

View File

@@ -1,12 +0,0 @@
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();
}
}

View File

@@ -1,35 +0,0 @@
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,
};
}
}

View File

@@ -1,36 +0,0 @@
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();
};
}

View File

@@ -1,7 +0,0 @@
export const FABRIC_PROTOCOL_VERSION = '1';
export function normalizeVersion(input?: string): string {
if (!input) return FABRIC_PROTOCOL_VERSION;
const v = input.trim();
return v;
}

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,16 +0,0 @@
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',
});

View File

@@ -1,25 +0,0 @@
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;
}

View File

@@ -1,38 +0,0 @@
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;
}

View File

@@ -1,19 +0,0 @@
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;
}

View File

@@ -1,69 +0,0 @@
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);
});
});

View File

@@ -1,56 +0,0 @@
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();

View File

@@ -1,14 +0,0 @@
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;
}

View File

@@ -1,6 +0,0 @@
import { IsIn } from 'class-validator';
export class UpdateNodeStatusDto {
@IsIn(['active', 'offline', 'revoked'])
status!: 'active' | 'offline' | 'revoked';
}

View File

@@ -1,196 +0,0 @@
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)),
};
}
}

View File

@@ -1,10 +0,0 @@
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 {}

View File

@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,8 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.spec.ts', 'src/*.integration.spec.ts'],
exclude: ['dist/**', 'node_modules/**'],
},
});

1
Fabric.Backend.Guild Submodule

Submodule Fabric.Backend.Guild added at d9c5175233

View File

@@ -1,23 +0,0 @@
# Server
PORT=7002
# MySQL
DB_HOST=mysql-guild
DB_PORT=3306
DB_USER=fabric
DB_PASSWORD=fabric
DB_NAME=fabric_guild
DB_SYNC=true
DB_LOGGING=false
# Unified inbound API auth
FABRIC_API_KEY=change-me-api-key
# Guild identity
GUILD_NODE_ID=guild-node-1
GUILD_NODE_NAME=Guild Node 1
GUILD_PUBLIC_ENDPOINT=http://localhost:7002
# Center handshake
CENTER_BASE_URL=http://backend-center:7001
CENTER_SHARED_SECRET=change-me-center-secret

View File

@@ -1,2 +0,0 @@
node_modules/
dist/

View File

@@ -1,19 +0,0 @@
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 7002
CMD ["node", "dist/main.js"]

View File

@@ -1,15 +0,0 @@
# Fabric.Backend.Guild
Guild Node service for Fabric.
## Scope (MVP)
- Workspace/Guild/Channel/DM
- Message create/edit/delete/reply/@mention
- Per-channel/DM seq ordering + gap backfill API
- Webhook/Bot integration surface
- Guild-level audit logs
## Next
- API skeleton (NestJS)
- Chat domain models
- Seq allocator and range query endpoints

View File

@@ -1,28 +0,0 @@
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,
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
{
"name": "fabric-backend-guild",
"version": "0.1.0",
"private": true,
"description": "Fabric Guild Node 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/platform-socket.io": "^10.4.22",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^10.4.22",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"mysql2": "^3.22.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.29"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@nestjs/testing": "^10.4.22",
"@types/express": "^5.0.6",
"@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"
}
}

View File

@@ -1,33 +0,0 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
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 { ApiKeyGuard } from './common/api-key.guard';
import { GuildsModule } from './guilds/guilds.module';
import { ChannelsModule } from './channels/channels.module';
import { MessagingModule } from './messaging/messaging.module';
import { EventsModule } from './events/events.module';
import { RealtimeModule } from './realtime/realtime.module';
@Module({
imports: [
TypeOrmModule.forRoot(buildTypeOrmConfig()),
EventsModule,
RealtimeModule,
GuildsModule,
ChannelsModule,
MessagingModule,
],
controllers: [HealthController, MetricsController],
providers: [
MetricsService,
{
provide: APP_GUARD,
useClass: ApiKeyGuard,
},
],
})
export class AppModule {}

View File

@@ -1,18 +0,0 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ChannelsService } from './channels.service';
@Controller('channels')
export class ChannelsController {
constructor(private readonly channelsService: ChannelsService) {}
@Get()
list(@Query('guildId') guildId?: string) {
if (!guildId) return [];
return this.channelsService.listByGuild(guildId);
}
@Post()
create(@Body() body: Record<string, unknown>) {
return this.channelsService.create(body);
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ChannelsController } from './channels.controller';
import { Channel } from '../entities/channel.entity';
import { ChannelsService } from './channels.service';
@Module({
imports: [TypeOrmModule.forFeature([Channel])],
controllers: [ChannelsController],
providers: [ChannelsService],
exports: [ChannelsService],
})
export class ChannelsModule {}

View File

@@ -1,31 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Channel } from '../entities/channel.entity';
@Injectable()
export class ChannelsService {
constructor(
@InjectRepository(Channel)
private readonly channelRepo: Repository<Channel>,
) {}
listByGuild(guildId: string) {
return this.channelRepo.find({
where: { guildId },
order: { createdAt: 'ASC' },
take: 200,
});
}
create(input: Partial<Channel>) {
const channel = this.channelRepo.create({
guildId: String(input.guildId ?? ''),
name: String(input.name ?? ''),
kind: input.kind === 'announcement' ? 'announcement' : 'text',
isPrivate: Boolean(input.isPrivate),
lastSeq: 0,
});
return this.channelRepo.save(channel);
}
}

View File

@@ -1,34 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<{ path?: string; headers: Record<string, string | string[] | undefined> }>();
const path = req.path ?? '';
// allow health check without auth
if (path.endsWith('/healthz') || path === '/healthz') {
return true;
}
const expected = process.env.FABRIC_API_KEY;
if (!expected || expected.trim() === '') {
throw new ServiceUnavailableException('FABRIC_API_KEY is not configured');
}
const received = req.headers['x-api-key'];
const receivedValue = Array.isArray(received) ? received[0] : received;
if (!receivedValue || receivedValue !== expected) {
throw new UnauthorizedException('invalid api key');
}
return true;
}
}

View File

@@ -1,9 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('healthz')
export class HealthController {
@Get()
get() {
return { ok: true, service: 'guild' };
}
}

View File

@@ -1,12 +0,0 @@
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();
}
}

View File

@@ -1,35 +0,0 @@
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,
};
}
}

View File

@@ -1,36 +0,0 @@
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();
};
}

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -1,32 +0,0 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { Guild } from './entities/guild.entity';
import { Channel } from './entities/channel.entity';
import { Message } from './entities/message.entity';
import { DmConversation } from './entities/dm-conversation.entity';
import { DmParticipant } from './entities/dm-participant.entity';
import { GuildRole } from './entities/guild-role.entity';
import { GuildMember } from './entities/guild-member.entity';
import { GuildMemberRole } from './entities/guild-member-role.entity';
import { IdempotencyRecord } from './entities/idempotency-record.entity';
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
type: 'mysql',
host: process.env.DB_HOST ?? 'mysql-guild',
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_guild',
entities: [
Guild,
Channel,
Message,
DmConversation,
DmParticipant,
GuildRole,
GuildMember,
GuildMemberRole,
IdempotencyRecord,
],
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
});

View File

@@ -1,29 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('channels')
@Index(['guildId', 'createdAt'])
export class Channel {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index()
@Column({ type: 'char', length: 36 })
guildId!: string;
@Column({ type: 'varchar', length: 120 })
name!: string;
@Column({ type: 'varchar', length: 16, default: 'text' })
kind!: 'text' | 'announcement';
@Column({ type: 'boolean', default: false })
isPrivate!: boolean;
@Index()
@Column({ default: 0 })
lastSeq!: number;
@CreateDateColumn()
@Index()
createdAt!: Date;
}

View File

@@ -1,17 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('dm_conversations')
export class DmConversation {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 64, unique: true })
pairKey!: string;
@Column({ type: 'varchar', length: 255, nullable: true })
topic!: string | null;
@CreateDateColumn()
@Index()
createdAt!: Date;
}

View File

@@ -1,23 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('dm_participants')
@Index(['conversationId', 'userId'], { unique: true })
export class DmParticipant {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index()
@Column({ type: 'char', length: 36 })
conversationId!: string;
@Index()
@Column({ type: 'varchar', length: 64 })
userId!: string;
@Column({ type: 'varchar', length: 16, default: 'member' })
role!: 'member';
@CreateDateColumn()
@Index()
createdAt!: Date;
}

View File

@@ -1,19 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('guild_member_roles')
export class GuildMemberRole {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'char', length: 36 })
guildId!: string;
@Column({ type: 'char', length: 36 })
memberId!: string;
@Column({ type: 'char', length: 36 })
roleId!: string;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,24 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('guild_members')
@Index(['guildId', 'userId'], { unique: true })
@Index(['guildId', 'status'])
export class GuildMember {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index()
@Column({ type: 'char', length: 36 })
guildId!: string;
@Index()
@Column({ type: 'varchar', length: 64 })
userId!: string;
@Column({ type: 'varchar', length: 16, default: 'active' })
status!: 'active' | 'left' | 'blocked';
@CreateDateColumn()
@Index()
createdAt!: Date;
}

View File

@@ -1,22 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('guild_roles')
export class GuildRole {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'char', length: 36 })
guildId!: string;
@Column({ type: 'varchar', length: 64 })
code!: string;
@Column({ type: 'varchar', length: 120 })
name!: string;
@Column({ type: 'boolean', default: false })
isSystem!: boolean;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,19 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('guilds')
export class Guild {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 120 })
name!: string;
@Column({ type: 'varchar', length: 120, unique: true })
slug!: string;
@Column({ type: 'varchar', length: 64, nullable: true })
ownerUserId!: string | null;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,26 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('idempotency_records')
@Index(['scope', 'idempotencyKey'], { unique: true })
export class IdempotencyRecord {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 64 })
scope!: string;
@Column({ type: 'varchar', length: 128 })
idempotencyKey!: string;
@Column({ type: 'json' })
responseBody!: Record<string, unknown>;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -1,53 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('messages')
@Index(['channelId', 'seq'], { unique: true })
@Index(['conversationId', 'seq'], { unique: true })
@Index(['channelId', 'createdAt'])
@Index(['conversationId', 'createdAt'])
export class Message {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80, unique: true })
messageId!: string;
@Index()
@Column({ type: 'char', length: 36, nullable: true })
channelId!: string | null;
@Index()
@Column({ type: 'char', length: 36, nullable: true })
conversationId!: string | null;
@Column({ type: 'varchar', length: 64 })
authorUserId!: string;
@Column()
seq!: number;
@Column({ type: 'text' })
content!: string;
@Column({ type: 'varchar', length: 80, nullable: true })
replyToMessageId!: string | null;
@Column({ type: 'json', nullable: true })
mentions!: string[] | null;
@Column({ type: 'json', nullable: true })
attachments!: Array<{ url: string; name?: string; mimeType?: string }> | null;
@Column({ type: 'datetime', nullable: true })
editedAt!: Date | null;
@Column({ type: 'datetime', nullable: true })
deletedAt!: Date | null;
@Column({ type: 'boolean', default: false })
isDeleted!: boolean;
@CreateDateColumn()
@Index()
createdAt!: Date;
}

View File

@@ -1,9 +0,0 @@
export type FabricEventEnvelope = {
event_id: string;
event_type: string;
occurred_at: string;
guild_id: string | null;
channel_id: string | null;
actor_id: string | null;
data: Record<string, unknown>;
};

View File

@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { EventsService } from './events.service';
@Global()
@Module({
providers: [EventsService],
exports: [EventsService],
})
export class EventsModule {}

View File

@@ -1,168 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { createHmac, randomUUID } from 'crypto';
import { FabricEventEnvelope } from './event-envelope';
type RetryTask = {
envelope: FabricEventEnvelope;
attempts: number;
nextRunAt: number;
};
@Injectable()
export class EventsService {
private readonly logger = new Logger(EventsService.name);
private readonly sentEventIds = new Map<string, number>();
private readonly retryQueue: RetryTask[] = [];
private retryTimer: NodeJS.Timeout | null = null;
private cleanupSentCache(now: number): void {
const ttlMs = 10 * 60 * 1000;
for (const [eventId, ts] of this.sentEventIds.entries()) {
if (now - ts > ttlMs) this.sentEventIds.delete(eventId);
}
}
private signWebhook(payload: string, timestamp: string, nonce: string): string {
const secret = process.env.FABRIC_WEBHOOK_SECRET;
if (!secret) return '';
const canonical = ['POST', '/webhook/events', timestamp, nonce, payload].join('\n');
return createHmac('sha256', secret).update(canonical).digest('hex');
}
private scheduleRetryPump(): void {
if (this.retryTimer) return;
this.retryTimer = setInterval(() => {
void this.processRetryQueue();
}, 1000);
}
private enqueueRetry(envelope: FabricEventEnvelope, attempts: number): void {
if (attempts >= 5) {
this.logger.warn(`drop event after max retries: ${envelope.event_id}`);
return;
}
const delayMs = Math.pow(2, attempts) * 1000; // 1s,2s,4s,8s,16s
this.retryQueue.push({
envelope,
attempts: attempts + 1,
nextRunAt: Date.now() + delayMs,
});
this.scheduleRetryPump();
}
private async deliverEnvelope(envelope: FabricEventEnvelope): Promise<boolean> {
const webhookUrl = process.env.FABRIC_WEBHOOK_URL;
if (!webhookUrl) {
this.logger.log(`event(no-webhook): ${JSON.stringify(envelope)}`);
return true;
}
const timestamp = new Date().toISOString();
const nonce = randomUUID();
const payload = JSON.stringify(envelope);
const signature = this.signWebhook(payload, timestamp, nonce);
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-fabric-version': '1',
'x-fabric-timestamp': timestamp,
'x-fabric-nonce': nonce,
'x-fabric-signature': signature,
},
body: payload,
});
if (response.status >= 200 && response.status < 300) {
return true;
}
// retry only transient statuses
if ([429, 500, 502, 503, 504].includes(response.status)) {
return false;
}
// permanent failure: don't retry
this.logger.warn(`event delivery permanent failure: ${response.status}`);
return true;
}
private async processRetryQueue(): Promise<void> {
const now = Date.now();
const due = this.retryQueue.filter((t) => t.nextRunAt <= now);
if (due.length === 0) return;
for (const task of due) {
const idx = this.retryQueue.indexOf(task);
if (idx >= 0) this.retryQueue.splice(idx, 1);
try {
const delivered = await this.deliverEnvelope(task.envelope);
if (delivered) {
this.sentEventIds.set(task.envelope.event_id, Date.now());
} else {
this.enqueueRetry(task.envelope, task.attempts);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`retry delivery failed: ${message}`);
this.enqueueRetry(task.envelope, task.attempts);
}
}
}
buildEnvelope(input: {
eventType: string;
guildId?: string | null;
channelId?: string | null;
actorId?: string | null;
data: Record<string, unknown>;
}): FabricEventEnvelope {
return {
event_id: randomUUID(),
event_type: input.eventType,
occurred_at: new Date().toISOString(),
guild_id: input.guildId ?? null,
channel_id: input.channelId ?? null,
actor_id: input.actorId ?? null,
data: input.data,
};
}
async emit(input: {
eventType: string;
guildId?: string | null;
channelId?: string | null;
actorId?: string | null;
data: Record<string, unknown>;
}): Promise<FabricEventEnvelope> {
const envelope = this.buildEnvelope(input);
const now = Date.now();
this.cleanupSentCache(now);
if (this.sentEventIds.has(envelope.event_id)) {
this.logger.warn(`skip duplicate event_id: ${envelope.event_id}`);
return envelope;
}
const webhookUrl = process.env.FABRIC_WEBHOOK_URL;
try {
const delivered = await this.deliverEnvelope(envelope);
if (delivered) {
this.sentEventIds.set(envelope.event_id, now);
} else {
this.enqueueRetry(envelope, 0);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`event delivery failed: ${message}`);
if (webhookUrl) {
this.enqueueRetry(envelope, 0);
}
}
return envelope;
}
}

View File

@@ -1,17 +0,0 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { GuildsService } from './guilds.service';
@Controller('guilds')
export class GuildsController {
constructor(private readonly guildsService: GuildsService) {}
@Get()
list() {
return this.guildsService.list();
}
@Post()
create(@Body() body: Record<string, unknown>) {
return this.guildsService.create(body);
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GuildsController } from './guilds.controller';
import { Guild } from '../entities/guild.entity';
import { GuildsService } from './guilds.service';
@Module({
imports: [TypeOrmModule.forFeature([Guild])],
controllers: [GuildsController],
providers: [GuildsService],
exports: [GuildsService],
})
export class GuildsModule {}

View File

@@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Guild } from '../entities/guild.entity';
@Injectable()
export class GuildsService {
constructor(
@InjectRepository(Guild)
private readonly guildRepo: Repository<Guild>,
) {}
list() {
return this.guildRepo.find({
order: { createdAt: 'DESC' },
take: 100,
});
}
create(input: Partial<Guild>) {
const slug = String(input.slug ?? '').trim();
const name = String(input.name ?? '').trim();
const ownerUserId = input.ownerUserId ? String(input.ownerUserId) : null;
const guild = this.guildRepo.create({ slug, name, ownerUserId });
return this.guildRepo.save(guild);
}
}

View File

@@ -1,77 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DataSource } from 'typeorm';
import { Channel } from './entities/channel.entity';
process.env.DB_HOST = '127.0.0.1';
process.env.DB_PORT = '3308';
process.env.DB_USER = 'fabric';
process.env.DB_PASSWORD = 'fabric';
process.env.DB_NAME = 'fabric_guild';
process.env.DB_SYNC = 'false';
process.env.FABRIC_API_KEY = 'test-api-key';
describe('guild integration (mysql + api)', () => {
let app: INestApplication;
let dataSource: DataSource;
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();
dataSource = app.get(DataSource);
await dataSource.dropDatabase();
await dataSource.synchronize();
}, 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.service).toBe('guild');
});
it('protects non-health endpoints by x-api-key', async () => {
const res = await request(app.getHttpServer()).get('/api/channels/non-exist/messages');
expect(res.status).toBe(401);
});
it('supports concurrent message writes with continuous seq', async () => {
const channelRepo = dataSource.getRepository(Channel);
const channel = await channelRepo.save(
channelRepo.create({
guildId: 'test-guild',
name: `concurrency-${Date.now()}`,
kind: 'text',
isPrivate: false,
lastSeq: 0,
}),
);
const concurrent = 10;
const tasks = Array.from({ length: concurrent }, (_, i) =>
request(app.getHttpServer())
.post(`/api/channels/${channel.id}/messages`)
.set('x-api-key', 'test-api-key')
.send({ content: `hello-${i + 1}`, authorUserId: 'u1' }),
);
const responses = await Promise.all(tasks);
const seqs = responses.map((r) => r.body.seq).sort((a, b) => a - b);
expect(responses.every((r) => r.status === 201)).toBe(true);
expect(new Set(seqs).size).toBe(concurrent);
expect(seqs).toEqual(Array.from({ length: concurrent }, (_, i) => i + 1));
});
});

View File

@@ -1,35 +0,0 @@
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';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
const metrics = app.get(MetricsService);
app.use(createRequestContextMiddleware('guild', metrics));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const swaggerConfig = new DocumentBuilder()
.setTitle('Fabric Backend Guild API')
.setDescription('Guild Node 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) : 7002;
await app.listen(port);
console.log(`Fabric.Backend.Guild listening on :${port}`);
}
void bootstrap();

View File

@@ -1,59 +0,0 @@
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
class AttachmentDto {
@IsString()
@MaxLength(2048)
url!: string;
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(100)
mimeType?: string;
}
export class CreateMessageDto {
@IsString()
@MaxLength(4000)
content!: string;
@IsOptional()
@IsString()
@MaxLength(80)
clientMessageId?: string;
@IsOptional()
@IsString()
@MaxLength(80)
replyToMessageId?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
mentions?: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(10)
@ValidateNested({ each: true })
@Type(() => AttachmentDto)
attachments?: AttachmentDto[];
@IsOptional()
@IsString()
@MaxLength(64)
authorUserId?: string;
}

View File

@@ -1,278 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
NotFoundException,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { CreateMessageDto } from './dto.create-message.dto';
import { Channel } from '../entities/channel.entity';
import { Message } from '../entities/message.entity';
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
import { EventsService } from '../events/events.service';
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
import { RealtimeGateway } from '../realtime/realtime.gateway';
const EDIT_WINDOW_MS = 15 * 60 * 1000;
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
@Controller('channels/:id/messages')
export class MessagingController {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(Channel)
private readonly channelRepo: Repository<Channel>,
@InjectRepository(Message)
private readonly messageRepo: Repository<Message>,
@InjectRepository(IdempotencyRecord)
private readonly idemRepo: Repository<IdempotencyRecord>,
private readonly events: EventsService,
private readonly realtime: RealtimeGateway,
) {}
private async getIdempotentResponse(
scope: string,
idempotencyKey?: string,
): Promise<Record<string, unknown> | null> {
if (!idempotencyKey) return null;
const row = await this.idemRepo.findOne({ where: { scope, idempotencyKey } });
return row?.responseBody ?? null;
}
private async saveIdempotentResponse(
scope: string,
idempotencyKey: string | undefined,
responseBody: Record<string, unknown>,
): Promise<void> {
if (!idempotencyKey) return;
const row = this.idemRepo.create({
scope,
idempotencyKey,
responseBody,
});
await this.idemRepo.save(row);
}
private toView(m: Message) {
return {
messageId: m.messageId,
seq: m.seq,
content: m.content,
authorUserId: m.authorUserId,
replyToMessageId: m.replyToMessageId,
mentions: m.mentions ?? [],
attachments: m.attachments ?? [],
createdAt: m.createdAt.toISOString(),
editedAt: m.editedAt ? m.editedAt.toISOString() : null,
deletedAt: m.deletedAt ? m.deletedAt.toISOString() : null,
isDeleted: m.isDeleted,
};
}
@Post()
async create(
@Param('id') channelId: string,
@Body() body: CreateMessageDto,
@Headers('idempotency-key') idempotencyKey?: string,
) {
const scope = `POST:/channels/${channelId}/messages`;
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
if (existed) return existed;
const message = await this.dataSource.transaction(async (manager) => {
const channel = await manager.findOne(Channel, {
where: { id: channelId },
lock: { mode: 'pessimistic_write' },
});
if (!channel) {
throw new NotFoundException('channel not found');
}
const nextSeq = channel.lastSeq + 1;
channel.lastSeq = nextSeq;
await manager.save(Channel, channel);
const messageId = body.clientMessageId ?? `m-${channelId}-${nextSeq}`;
const row = manager.create(Message, {
messageId,
channelId,
conversationId: null,
authorUserId: body.authorUserId ?? 'anonymous',
seq: nextSeq,
content: body.content,
replyToMessageId: body.replyToMessageId ?? null,
mentions: body.mentions ?? [],
attachments: body.attachments ?? [],
editedAt: null,
deletedAt: null,
isDeleted: false,
});
return manager.save(Message, row);
});
const responseBody = this.toView(message) as Record<string, unknown>;
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
await this.events.emit({
eventType: 'message.created',
channelId,
actorId: body.authorUserId ?? 'anonymous',
data: responseBody,
});
this.realtime.emitChannelEvent(channelId, 'message.created', responseBody);
return responseBody;
}
@Patch(':messageId')
async edit(
@Param('id') channelId: string,
@Param('messageId') messageId: string,
@Body() body: { content?: string },
@Headers('idempotency-key') idempotencyKey?: string,
) {
const scope = `PATCH:/channels/${channelId}/messages/${messageId}`;
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
if (existed) return existed;
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
if (!item) return { status: 'not_found' };
const now = Date.now();
const createdAt = new Date(item.createdAt).getTime();
if (now - createdAt > EDIT_WINDOW_MS) {
return { status: 'edit_window_expired', messageId };
}
item.content = body.content ?? item.content;
item.editedAt = new Date();
const saved = await this.messageRepo.save(item);
const responseBody = this.toView(saved) as Record<string, unknown>;
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
await this.events.emit({
eventType: 'message.updated',
channelId,
actorId: saved.authorUserId,
data: responseBody,
});
this.realtime.emitChannelEvent(channelId, 'message.updated', responseBody);
return responseBody;
}
@Delete(':messageId')
async remove(
@Param('id') channelId: string,
@Param('messageId') messageId: string,
@Headers('idempotency-key') idempotencyKey?: string,
) {
const scope = `DELETE:/channels/${channelId}/messages/${messageId}`;
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
if (existed) return existed;
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
if (!item) return { status: 'not_found' };
item.isDeleted = true;
item.deletedAt = new Date();
item.content = '[deleted]';
item.mentions = [];
item.attachments = [];
await this.messageRepo.save(item);
const responseBody = {
status: 'deleted',
mode: 'soft',
messageId,
} as Record<string, unknown>;
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
await this.events.emit({
eventType: 'message.deleted',
channelId,
actorId: item.authorUserId,
data: {
messageId,
seq: item.seq,
deletedAt: item.deletedAt?.toISOString() ?? null,
},
});
this.realtime.emitChannelEvent(channelId, 'message.deleted', {
messageId,
seq: item.seq,
deletedAt: item.deletedAt?.toISOString() ?? null,
});
return responseBody;
}
@Get()
async listBySeq(
@Param('id') channelId: string,
@Query('seq_from') seqFrom?: string,
@Query('seq_to') seqTo?: string,
@Query('limit') limit?: string,
) {
const from = seqFrom ? Number(seqFrom) : 1;
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
const safeLimit = clampLimit(limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
if (from > to) {
return {
items: [],
page: {
seqFrom: from,
seqTo: to,
limit: safeLimit,
returned: 0,
hasMore: false,
nextExpectedSeq: from,
highestCommittedSeq: 0,
},
};
}
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
if (!channel) {
throw new NotFoundException('channel not found');
}
const qb = this.messageRepo
.createQueryBuilder('m')
.where('m.channelId = :channelId', { channelId })
.andWhere('m.seq >= :from', { from })
.andWhere('m.seq <= :to', { to })
.orderBy('m.seq', 'ASC');
const total = await qb.getCount();
const rows = await qb.limit(safeLimit).getMany();
const items = rows.map((m) => this.toView(m));
const nextExpectedSeq = computeNextExpectedSeq(
from,
rows.map((row) => row.seq),
);
return {
items,
page: {
seqFrom: from,
seqTo: to,
limit: safeLimit,
returned: items.length,
hasMore: total > items.length,
nextExpectedSeq,
highestCommittedSeq: channel.lastSeq,
},
};
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MessagingController } from './messaging.controller';
import { Channel } from '../entities/channel.entity';
import { Message } from '../entities/message.entity';
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
@Module({
imports: [TypeOrmModule.forFeature([Channel, Message, IdempotencyRecord])],
controllers: [MessagingController],
})
export class MessagingModule {}

View File

@@ -1,15 +0,0 @@
import { describe, expect, it } from 'vitest';
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
describe('pagination utils', () => {
it('clamps limit safely', () => {
expect(clampLimit(undefined, 50, 200)).toBe(50);
expect(clampLimit('500', 50, 200)).toBe(200);
expect(clampLimit('-1', 50, 200)).toBe(50);
});
it('computes next expected seq', () => {
expect(computeNextExpectedSeq(1, [1, 2, 3])).toBe(4);
expect(computeNextExpectedSeq(1, [1, 3, 4])).toBe(2);
});
});

View File

@@ -1,14 +0,0 @@
export function clampLimit(input: string | undefined, defaultLimit: number, maxLimit: number): number {
const requested = input ? Number(input) : defaultLimit;
if (!Number.isFinite(requested) || requested <= 0) return defaultLimit;
return Math.min(requested, maxLimit);
}
export function computeNextExpectedSeq(from: number, seqs: number[]): number {
let next = from;
for (const seq of seqs) {
if (seq > next) break;
if (seq === next) next += 1;
}
return next;
}

View File

@@ -1,129 +0,0 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({
namespace: '/realtime',
cors: {
origin: '*',
},
})
export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(RealtimeGateway.name);
private readonly onlineUsers = new Set<string>();
private userIdFromClient(client: Socket): string {
const authUser = client.handshake.auth?.userId;
const headerUser = client.handshake.headers['x-user-id'];
const userId = typeof authUser === 'string' ? authUser : Array.isArray(headerUser) ? headerUser[0] : headerUser;
return userId && typeof userId === 'string' && userId.trim() !== '' ? userId : `anon:${client.id}`;
}
handleConnection(client: Socket): void {
const expected = process.env.FABRIC_API_KEY;
if (!expected) {
client.disconnect(true);
return;
}
const authKey = client.handshake.auth?.apiKey;
const headerKey = client.handshake.headers['x-api-key'];
const apiKey = typeof authKey === 'string' ? authKey : Array.isArray(headerKey) ? headerKey[0] : headerKey;
if (apiKey !== expected) {
this.logger.warn(`socket rejected: ${client.id}`);
client.disconnect(true);
return;
}
this.logger.log(`socket connected: ${client.id}`);
const userId = this.userIdFromClient(client);
client.data.userId = userId;
this.onlineUsers.add(userId);
this.server.emit('presence.online', {
userId,
onlineCount: this.onlineUsers.size,
occurredAt: new Date().toISOString(),
});
}
handleDisconnect(client: Socket): void {
this.logger.log(`socket disconnected: ${client.id}`);
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
this.onlineUsers.delete(userId);
this.server.emit('presence.offline', {
userId,
onlineCount: this.onlineUsers.size,
occurredAt: new Date().toISOString(),
});
}
@SubscribeMessage('join_channel')
joinChannel(
@ConnectedSocket() client: Socket,
@MessageBody() body: { channelId?: string },
): { ok: boolean } {
if (!body?.channelId) return { ok: false };
client.join(`channel:${body.channelId}`);
return { ok: true };
}
@SubscribeMessage('leave_channel')
leaveChannel(
@ConnectedSocket() client: Socket,
@MessageBody() body: { channelId?: string },
): { ok: boolean } {
if (!body?.channelId) return { ok: false };
client.leave(`channel:${body.channelId}`);
return { ok: true };
}
@SubscribeMessage('typing.start')
typingStart(
@ConnectedSocket() client: Socket,
@MessageBody() body: { channelId?: string },
): { ok: boolean } {
if (!body?.channelId) return { ok: false };
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
this.server.to(`channel:${body.channelId}`).emit('typing.start', {
channelId: body.channelId,
userId,
occurredAt: new Date().toISOString(),
});
return { ok: true };
}
@SubscribeMessage('typing.stop')
typingStop(
@ConnectedSocket() client: Socket,
@MessageBody() body: { channelId?: string },
): { ok: boolean } {
if (!body?.channelId) return { ok: false };
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
this.server.to(`channel:${body.channelId}`).emit('typing.stop', {
channelId: body.channelId,
userId,
occurredAt: new Date().toISOString(),
});
return { ok: true };
}
emitChannelEvent(channelId: string, event: string, data: Record<string, unknown>): void {
this.server.to(`channel:${channelId}`).emit(event, data);
}
}

View File

@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { RealtimeGateway } from './realtime.gateway';
@Global()
@Module({
providers: [RealtimeGateway],
exports: [RealtimeGateway],
})
export class RealtimeModule {}

View File

@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,8 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.spec.ts', 'src/*.integration.spec.ts'],
exclude: ['dist/**', 'node_modules/**'],
},
});