Compare commits
26 Commits
7f68a09486
...
5b28ad52bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b28ad52bb | |||
| 8534c530c8 | |||
| 41a4172267 | |||
| ec796ae609 | |||
| 0731778bd3 | |||
| b014767324 | |||
| 11aa538793 | |||
| 24cbee3135 | |||
| 7e458ad6d3 | |||
| 37ec670280 | |||
| 8ca5d68ba4 | |||
| 3795aea2cb | |||
| 676e838697 | |||
| ab01a83a90 | |||
| 670762aa7a | |||
| 2ec50f3234 | |||
| fa5d0d31b2 | |||
| 4b4755b33b | |||
| c08fa4756b | |||
| 325e13ee13 | |||
| d3fdc3dd1e | |||
| ceaece754e | |||
| e53c943991 | |||
| 46f138328e | |||
| 2e2e217b5f | |||
| 7270256587 |
1594
Fabric.Backend.Center/package-lock.json
generated
1594
Fabric.Backend.Center/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,15 @@
|
|||||||
"start:dev": "ts-node src/main.ts",
|
"start:dev": "ts-node src/main.ts",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
"format": "prettier --write 'src/**/*.ts'"
|
"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/**"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.8",
|
"@nestjs/common": "^10.4.8",
|
||||||
"@nestjs/core": "^10.4.8",
|
"@nestjs/core": "^10.4.8",
|
||||||
"@nestjs/platform-express": "^10.4.8",
|
"@nestjs/platform-express": "^10.4.8",
|
||||||
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -23,19 +26,24 @@
|
|||||||
"mysql2": "^3.22.3",
|
"mysql2": "^3.22.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.29"
|
"typeorm": "^0.3.29"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@nestjs/testing": "^10.4.22",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||||
"@typescript-eslint/parser": "^8.59.3",
|
"@typescript-eslint/parser": "^8.59.3",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import { buildTypeOrmConfig } from './database.config';
|
|||||||
import { HealthController } from './common/health.controller';
|
import { HealthController } from './common/health.controller';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { NodesModule } from './nodes/nodes.module';
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forRoot(buildTypeOrmConfig()), AuthModule, NodesModule],
|
imports: [
|
||||||
|
TypeOrmModule.forRoot(buildTypeOrmConfig()),
|
||||||
|
AuditModule,
|
||||||
|
AuthModule,
|
||||||
|
NodesModule,
|
||||||
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
12
Fabric.Backend.Center/src/audit/audit.module.ts
Normal file
12
Fabric.Backend.Center/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
Fabric.Backend.Center/src/audit/audit.service.ts
Normal file
31
Fabric.Backend.Center/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,22 +10,8 @@ import * as jwt from 'jsonwebtoken';
|
|||||||
import { User } from '../entities/user.entity';
|
import { User } from '../entities/user.entity';
|
||||||
import { RegisterDto } from './dto.register.dto';
|
import { RegisterDto } from './dto.register.dto';
|
||||||
import { LoginDto } from './dto.login.dto';
|
import { LoginDto } from './dto.login.dto';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
function parseDurationToSeconds(input: string, fallbackSeconds: number): number {
|
import { parseDurationToSeconds } from './token.util';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function signAccessToken(userId: string, email: string): string {
|
function signAccessToken(userId: string, email: string): string {
|
||||||
const secret = process.env.JWT_ACCESS_SECRET as string;
|
const secret = process.env.JWT_ACCESS_SECRET as string;
|
||||||
@@ -44,6 +30,7 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly userRepo: Repository<User>,
|
private readonly userRepo: Repository<User>,
|
||||||
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(input: RegisterDto) {
|
async register(input: RegisterDto) {
|
||||||
@@ -60,6 +47,14 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
const saved = await this.userRepo.save(user);
|
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 {
|
return {
|
||||||
id: saved.id,
|
id: saved.id,
|
||||||
email: saved.email,
|
email: saved.email,
|
||||||
@@ -79,6 +74,14 @@ export class AuthService {
|
|||||||
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
||||||
await this.userRepo.save(user);
|
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 {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
@@ -112,6 +115,14 @@ export class AuthService {
|
|||||||
user.refreshTokenHash = await bcrypt.hash(newRefreshToken, 10);
|
user.refreshTokenHash = await bcrypt.hash(newRefreshToken, 10);
|
||||||
await this.userRepo.save(user);
|
await this.userRepo.save(user);
|
||||||
|
|
||||||
|
await this.audit.write({
|
||||||
|
action: 'auth.refresh',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
detail: null,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: newAccessToken,
|
accessToken: newAccessToken,
|
||||||
refreshToken: newRefreshToken,
|
refreshToken: newRefreshToken,
|
||||||
@@ -135,6 +146,13 @@ export class AuthService {
|
|||||||
|
|
||||||
user.refreshTokenHash = null;
|
user.refreshTokenHash = null;
|
||||||
await this.userRepo.save(user);
|
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' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
Fabric.Backend.Center/src/auth/token.util.spec.ts
Normal file
14
Fabric.Backend.Center/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
Fabric.Backend.Center/src/auth/token.util.ts
Normal file
15
Fabric.Backend.Center/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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
@Controller('healthz')
|
@Controller('healthz')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
get() {
|
async get() {
|
||||||
return { ok: true, service: 'center' };
|
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
Fabric.Backend.Center/src/common/hmac.ts
Normal file
38
Fabric.Backend.Center/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);
|
||||||
|
}
|
||||||
7
Fabric.Backend.Center/src/common/version.ts
Normal file
7
Fabric.Backend.Center/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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
import { User } from './entities/user.entity';
|
import { User } from './entities/user.entity';
|
||||||
import { GuildNode } from './entities/guild-node.entity';
|
import { GuildNode } from './entities/guild-node.entity';
|
||||||
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
|
|
||||||
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
@@ -9,7 +10,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
|||||||
username: process.env.DB_USER ?? 'fabric',
|
username: process.env.DB_USER ?? 'fabric',
|
||||||
password: process.env.DB_PASSWORD ?? 'fabric',
|
password: process.env.DB_PASSWORD ?? 'fabric',
|
||||||
database: process.env.DB_NAME ?? 'fabric_center',
|
database: process.env.DB_NAME ?? 'fabric_center',
|
||||||
entities: [User, GuildNode],
|
entities: [User, GuildNode, AuditLog],
|
||||||
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
||||||
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
||||||
});
|
});
|
||||||
|
|||||||
25
Fabric.Backend.Center/src/entities/audit-log.entity.ts
Normal file
25
Fabric.Backend.Center/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;
|
||||||
|
}
|
||||||
40
Fabric.Backend.Center/src/health.integration.spec.ts
Normal file
40
Fabric.Backend.Center/src/health.integration.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
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 = 'false';
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
@@ -34,6 +35,15 @@ async function bootstrap() {
|
|||||||
transform: 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;
|
const port = process.env.PORT ? Number(process.env.PORT) : 7001;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`Fabric.Backend.Center listening on :${port}`);
|
console.log(`Fabric.Backend.Center listening on :${port}`);
|
||||||
|
|||||||
@@ -11,8 +11,4 @@ export class RegisterNodeDto {
|
|||||||
|
|
||||||
@IsUrl({ require_tld: false })
|
@IsUrl({ require_tld: false })
|
||||||
endpoint!: string;
|
endpoint!: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
sharedSecret!: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
@@ -15,19 +17,62 @@ import {
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { GuildNode } from '../entities/guild-node.entity';
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { RegisterNodeDto } from './dto.register-node.dto';
|
import { RegisterNodeDto } from './dto.register-node.dto';
|
||||||
import { UpdateNodeStatusDto } from './dto.update-node-status.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')
|
@Controller('nodes')
|
||||||
export class NodesController {
|
export class NodesController {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(GuildNode)
|
@InjectRepository(GuildNode)
|
||||||
private readonly nodeRepo: Repository<GuildNode>,
|
private readonly nodeRepo: Repository<GuildNode>,
|
||||||
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
async register(@Body() body: RegisterNodeDto) {
|
async register(
|
||||||
if (body.sharedSecret !== process.env.CENTER_SHARED_SECRET) {
|
@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');
|
throw new ForbiddenException('invalid shared secret');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +97,16 @@ export class NodesController {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
});
|
});
|
||||||
const saved = await this.nodeRepo.save(node);
|
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 {
|
return {
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
|
negotiatedVersion: FABRIC_PROTOCOL_VERSION,
|
||||||
node: {
|
node: {
|
||||||
id: saved.id,
|
id: saved.id,
|
||||||
nodeId: saved.nodeId,
|
nodeId: saved.nodeId,
|
||||||
@@ -78,6 +130,12 @@ export class NodesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saved = await this.nodeRepo.save(node);
|
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 {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
nodeId: saved.nodeId,
|
nodeId: saved.nodeId,
|
||||||
@@ -98,6 +156,12 @@ export class NodesController {
|
|||||||
|
|
||||||
node.status = body.status;
|
node.status = body.status;
|
||||||
const saved = await this.nodeRepo.save(node);
|
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 {
|
return {
|
||||||
id: saved.id,
|
id: saved.id,
|
||||||
nodeId: saved.nodeId,
|
nodeId: saved.nodeId,
|
||||||
|
|||||||
8
Fabric.Backend.Center/vitest.config.ts
Normal file
8
Fabric.Backend.Center/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/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -10,6 +10,9 @@ DB_NAME=fabric_guild
|
|||||||
DB_SYNC=true
|
DB_SYNC=true
|
||||||
DB_LOGGING=false
|
DB_LOGGING=false
|
||||||
|
|
||||||
|
# Unified inbound API auth
|
||||||
|
FABRIC_API_KEY=change-me-api-key
|
||||||
|
|
||||||
# Guild identity
|
# Guild identity
|
||||||
GUILD_NODE_ID=guild-node-1
|
GUILD_NODE_ID=guild-node-1
|
||||||
GUILD_NODE_NAME=Guild Node 1
|
GUILD_NODE_NAME=Guild Node 1
|
||||||
|
|||||||
1634
Fabric.Backend.Guild/package-lock.json
generated
1634
Fabric.Backend.Guild/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,27 +9,37 @@
|
|||||||
"start:dev": "ts-node src/main.ts",
|
"start:dev": "ts-node src/main.ts",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
"format": "prettier --write 'src/**/*.ts'"
|
"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/**"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.8",
|
"@nestjs/common": "^10.4.8",
|
||||||
"@nestjs/core": "^10.4.8",
|
"@nestjs/core": "^10.4.8",
|
||||||
"@nestjs/platform-express": "^10.4.8",
|
"@nestjs/platform-express": "^10.4.8",
|
||||||
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
"mysql2": "^3.22.3",
|
"mysql2": "^3.22.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.29"
|
"typeorm": "^0.3.29"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@nestjs/testing": "^10.4.22",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||||
"@typescript-eslint/parser": "^8.59.3",
|
"@typescript-eslint/parser": "^8.59.3",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { buildTypeOrmConfig } from './database.config';
|
import { buildTypeOrmConfig } from './database.config';
|
||||||
import { HealthController } from './common/health.controller';
|
import { HealthController } from './common/health.controller';
|
||||||
|
import { ApiKeyGuard } from './common/api-key.guard';
|
||||||
import { GuildsModule } from './guilds/guilds.module';
|
import { GuildsModule } from './guilds/guilds.module';
|
||||||
import { ChannelsModule } from './channels/channels.module';
|
import { ChannelsModule } from './channels/channels.module';
|
||||||
import { MessagingModule } from './messaging/messaging.module';
|
import { MessagingModule } from './messaging/messaging.module';
|
||||||
|
import { EventsModule } from './events/events.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forRoot(buildTypeOrmConfig()), GuildsModule, ChannelsModule, MessagingModule],
|
imports: [
|
||||||
|
TypeOrmModule.forRoot(buildTypeOrmConfig()),
|
||||||
|
EventsModule,
|
||||||
|
GuildsModule,
|
||||||
|
ChannelsModule,
|
||||||
|
MessagingModule,
|
||||||
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ApiKeyGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
34
Fabric.Backend.Guild/src/common/api-key.guard.ts
Normal file
34
Fabric.Backend.Guild/src/common/api-key.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
|||||||
import { Guild } from './entities/guild.entity';
|
import { Guild } from './entities/guild.entity';
|
||||||
import { Channel } from './entities/channel.entity';
|
import { Channel } from './entities/channel.entity';
|
||||||
import { Message } from './entities/message.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 => ({
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
@@ -10,7 +16,17 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
|||||||
username: process.env.DB_USER ?? 'fabric',
|
username: process.env.DB_USER ?? 'fabric',
|
||||||
password: process.env.DB_PASSWORD ?? 'fabric',
|
password: process.env.DB_PASSWORD ?? 'fabric',
|
||||||
database: process.env.DB_NAME ?? 'fabric_guild',
|
database: process.env.DB_NAME ?? 'fabric_guild',
|
||||||
entities: [Guild, Channel, Message],
|
entities: [
|
||||||
|
Guild,
|
||||||
|
Channel,
|
||||||
|
Message,
|
||||||
|
DmConversation,
|
||||||
|
DmParticipant,
|
||||||
|
GuildRole,
|
||||||
|
GuildMember,
|
||||||
|
GuildMemberRole,
|
||||||
|
IdempotencyRecord,
|
||||||
|
],
|
||||||
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
||||||
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
@Entity('channels')
|
@Entity('channels')
|
||||||
|
@Index(['guildId', 'createdAt'])
|
||||||
export class Channel {
|
export class Channel {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column()
|
@Index()
|
||||||
|
@Column({ type: 'char', length: 36 })
|
||||||
guildId!: string;
|
guildId!: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ type: 'varchar', length: 120 })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 16, default: 'text' })
|
||||||
|
kind!: 'text' | 'announcement';
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
isPrivate!: boolean;
|
||||||
|
|
||||||
@Index()
|
@Index()
|
||||||
@Column({ default: 0 })
|
@Column({ default: 0 })
|
||||||
lastSeq!: number;
|
lastSeq!: number;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
|
@Index()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
17
Fabric.Backend.Guild/src/entities/dm-conversation.entity.ts
Normal file
17
Fabric.Backend.Guild/src/entities/dm-conversation.entity.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
23
Fabric.Backend.Guild/src/entities/dm-participant.entity.ts
Normal file
23
Fabric.Backend.Guild/src/entities/dm-participant.entity.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
24
Fabric.Backend.Guild/src/entities/guild-member.entity.ts
Normal file
24
Fabric.Backend.Guild/src/entities/guild-member.entity.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
22
Fabric.Backend.Guild/src/entities/guild-role.entity.ts
Normal file
22
Fabric.Backend.Guild/src/entities/guild-role.entity.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -5,9 +5,15 @@ export class Guild {
|
|||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ type: 'varchar', length: 120 })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120, unique: true })
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
ownerUserId!: string | null;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -2,13 +2,27 @@ import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from
|
|||||||
|
|
||||||
@Entity('messages')
|
@Entity('messages')
|
||||||
@Index(['channelId', 'seq'], { unique: true })
|
@Index(['channelId', 'seq'], { unique: true })
|
||||||
|
@Index(['conversationId', 'seq'], { unique: true })
|
||||||
|
@Index(['channelId', 'createdAt'])
|
||||||
|
@Index(['conversationId', 'createdAt'])
|
||||||
export class Message {
|
export class Message {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Index()
|
@Index()
|
||||||
@Column()
|
@Column({ type: 'varchar', length: 80, unique: true })
|
||||||
channelId!: string;
|
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()
|
@Column()
|
||||||
seq!: number;
|
seq!: number;
|
||||||
@@ -16,6 +30,25 @@ export class Message {
|
|||||||
@Column({ type: 'text' })
|
@Column({ type: 'text' })
|
||||||
content!: string;
|
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()
|
@CreateDateColumn()
|
||||||
|
@Index()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
9
Fabric.Backend.Guild/src/events/event-envelope.ts
Normal file
9
Fabric.Backend.Guild/src/events/event-envelope.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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>;
|
||||||
|
};
|
||||||
9
Fabric.Backend.Guild/src/events/events.module.ts
Normal file
9
Fabric.Backend.Guild/src/events/events.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { EventsService } from './events.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [EventsService],
|
||||||
|
exports: [EventsService],
|
||||||
|
})
|
||||||
|
export class EventsModule {}
|
||||||
168
Fabric.Backend.Guild/src/events/events.service.ts
Normal file
168
Fabric.Backend.Guild/src/events/events.service.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Fabric.Backend.Guild/src/health.integration.spec.ts
Normal file
43
Fabric.Backend.Guild/src/health.integration.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
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;
|
const port = process.env.PORT ? Number(process.env.PORT) : 7002;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`Fabric.Backend.Guild listening on :${port}`);
|
console.log(`Fabric.Backend.Guild listening on :${port}`);
|
||||||
|
|||||||
59
Fabric.Backend.Guild/src/messaging/dto.create-message.dto.ts
Normal file
59
Fabric.Backend.Guild/src/messaging/dto.create-message.dto.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,62 +1,269 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
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';
|
||||||
|
|
||||||
type Message = {
|
const EDIT_WINDOW_MS = 15 * 60 * 1000;
|
||||||
messageId: string;
|
const DEFAULT_PAGE_LIMIT = 50;
|
||||||
seq: number;
|
const MAX_PAGE_LIMIT = 200;
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Controller('channels/:id/messages')
|
@Controller('channels/:id/messages')
|
||||||
export class MessagingController {
|
export class MessagingController {
|
||||||
private seqByChannel = new Map<string, number>();
|
constructor(
|
||||||
private messagesByChannel = new Map<string, Message[]>();
|
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 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()
|
@Post()
|
||||||
create(@Param('id') channelId: string, @Body() body: { content?: string; messageId?: string }) {
|
async create(
|
||||||
const next = (this.seqByChannel.get(channelId) ?? 0) + 1;
|
@Param('id') channelId: string,
|
||||||
this.seqByChannel.set(channelId, next);
|
@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: Message = {
|
const message = await this.dataSource.transaction(async (manager) => {
|
||||||
messageId: body.messageId ?? `m-${channelId}-${next}`,
|
const channel = await manager.findOne(Channel, {
|
||||||
seq: next,
|
where: { id: channelId },
|
||||||
content: body.content ?? '',
|
lock: { mode: 'pessimistic_write' },
|
||||||
};
|
});
|
||||||
|
if (!channel) {
|
||||||
|
throw new NotFoundException('channel not found');
|
||||||
|
}
|
||||||
|
|
||||||
const arr = this.messagesByChannel.get(channelId) ?? [];
|
const nextSeq = channel.lastSeq + 1;
|
||||||
arr.push(message);
|
channel.lastSeq = nextSeq;
|
||||||
this.messagesByChannel.set(channelId, arr);
|
await manager.save(Channel, channel);
|
||||||
|
|
||||||
return message;
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':messageId')
|
@Patch(':messageId')
|
||||||
edit(@Param('id') channelId: string, @Param('messageId') messageId: string, @Body() body: { content?: string }) {
|
async edit(
|
||||||
const arr = this.messagesByChannel.get(channelId) ?? [];
|
@Param('id') channelId: string,
|
||||||
const item = arr.find((m) => m.messageId === messageId);
|
@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' };
|
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.content = body.content ?? item.content;
|
||||||
return item;
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':messageId')
|
@Delete(':messageId')
|
||||||
remove(@Param('id') channelId: string, @Param('messageId') messageId: string) {
|
async remove(
|
||||||
const arr = this.messagesByChannel.get(channelId) ?? [];
|
@Param('id') channelId: string,
|
||||||
const next = arr.filter((m) => m.messageId !== messageId);
|
@Param('messageId') messageId: string,
|
||||||
this.messagesByChannel.set(channelId, next);
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
return { status: 'deleted', messageId };
|
) {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
listBySeq(
|
async listBySeq(
|
||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
@Query('seq_from') seqFrom?: string,
|
@Query('seq_from') seqFrom?: string,
|
||||||
@Query('seq_to') seqTo?: string,
|
@Query('seq_to') seqTo?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
) {
|
) {
|
||||||
const from = seqFrom ? Number(seqFrom) : 1;
|
const from = seqFrom ? Number(seqFrom) : 1;
|
||||||
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
|
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
|
||||||
const arr = this.messagesByChannel.get(channelId) ?? [];
|
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 {
|
return {
|
||||||
items: arr.filter((m) => m.seq >= from && m.seq <= to),
|
items,
|
||||||
|
page: {
|
||||||
|
seqFrom: from,
|
||||||
|
seqTo: to,
|
||||||
|
limit: safeLimit,
|
||||||
|
returned: items.length,
|
||||||
|
hasMore: total > items.length,
|
||||||
|
nextExpectedSeq,
|
||||||
|
highestCommittedSeq: channel.lastSeq,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MessagingController } from './messaging.controller';
|
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({
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Channel, Message, IdempotencyRecord])],
|
||||||
controllers: [MessagingController],
|
controllers: [MessagingController],
|
||||||
})
|
})
|
||||||
export class MessagingModule {}
|
export class MessagingModule {}
|
||||||
|
|||||||
15
Fabric.Backend.Guild/src/messaging/pagination.util.spec.ts
Normal file
15
Fabric.Backend.Guild/src/messaging/pagination.util.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
Fabric.Backend.Guild/src/messaging/pagination.util.ts
Normal file
14
Fabric.Backend.Guild/src/messaging/pagination.util.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
8
Fabric.Backend.Guild/vitest.config.ts
Normal file
8
Fabric.Backend.Guild/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/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,7 +8,6 @@ services:
|
|||||||
MYSQL_DATABASE: fabric_center
|
MYSQL_DATABASE: fabric_center
|
||||||
MYSQL_USER: fabric
|
MYSQL_USER: fabric
|
||||||
MYSQL_PASSWORD: fabric
|
MYSQL_PASSWORD: fabric
|
||||||
command: ["--default-authentication-plugin=mysql_native_password"]
|
|
||||||
ports:
|
ports:
|
||||||
- "3307:3306"
|
- "3307:3306"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -28,7 +27,6 @@ services:
|
|||||||
MYSQL_DATABASE: fabric_guild
|
MYSQL_DATABASE: fabric_guild
|
||||||
MYSQL_USER: fabric
|
MYSQL_USER: fabric
|
||||||
MYSQL_PASSWORD: fabric
|
MYSQL_PASSWORD: fabric
|
||||||
command: ["--default-authentication-plugin=mysql_native_password"]
|
|
||||||
ports:
|
ports:
|
||||||
- "3308:3306"
|
- "3308:3306"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -30,8 +30,8 @@
|
|||||||
- [x] node 心跳接口(可选)
|
- [x] node 心跳接口(可选)
|
||||||
|
|
||||||
### 1.3 Center 运维能力
|
### 1.3 Center 运维能力
|
||||||
- [ ] 审计日志(auth/node 关键操作)
|
- [x] 审计日志(auth/node 关键操作)
|
||||||
- [ ] 健康检查深化(DB ready)
|
- [x] 健康检查深化(DB ready)
|
||||||
- [x] 配置校验(启动时必填项检查)
|
- [x] 配置校验(启动时必填项检查)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -39,21 +39,21 @@
|
|||||||
## 2. Fabric.Backend.Guild(Guild Node)
|
## 2. Fabric.Backend.Guild(Guild Node)
|
||||||
|
|
||||||
### 2.1 领域模型
|
### 2.1 领域模型
|
||||||
- [ ] Guild/Channel/DM 实体补全
|
- [x] Guild/Channel/DM 实体补全
|
||||||
- [ ] Member/Role 基础模型(即使 MVP 权限全开,也先留结构)
|
- [x] Member/Role 基础模型(即使 MVP 权限全开,也先留结构)
|
||||||
- [ ] 索引设计(channel_id + seq, created_at 等)
|
- [x] 索引设计(channel_id + seq, created_at 等)
|
||||||
|
|
||||||
### 2.2 消息主链路
|
### 2.2 消息主链路
|
||||||
- [ ] 发送消息(content/reply/mentions/attachments 元数据)
|
- [x] 发送消息(content/reply/mentions/attachments 元数据)
|
||||||
- [ ] 编辑消息(可编辑窗口策略先简化)
|
- [x] 编辑消息(可编辑窗口策略先简化)
|
||||||
- [ ] 删除消息(软删 vs 硬删,先定策略)
|
- [x] 删除消息(软删 vs 硬删,先定策略)
|
||||||
- [ ] `GET messages` 分页(seq 区间 + limit)
|
- [x] `GET messages` 分页(seq 区间 + limit)
|
||||||
- [ ] seq 分配改为 DB 原子方案(避免并发冲突)
|
- [x] seq 分配改为 DB 原子方案(避免并发冲突)
|
||||||
|
|
||||||
### 2.3 一致性与回补
|
### 2.3 一致性与回补
|
||||||
- [ ] 回补接口:`seq_from/seq_to`
|
- [x] 回补接口:`seq_from/seq_to`
|
||||||
- [ ] 断片检测辅助响应字段(next_expected_seq 等)
|
- [x] 断片检测辅助响应字段(next_expected_seq 等)
|
||||||
- [ ] 幂等键支持(写接口)
|
- [x] 幂等键支持(写接口)
|
||||||
|
|
||||||
### 2.4 实时通信(MVP 后半)
|
### 2.4 实时通信(MVP 后半)
|
||||||
- [ ] WebSocket 网关接入
|
- [ ] WebSocket 网关接入
|
||||||
@@ -63,31 +63,31 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 3. Center ↔ Guild 协议层
|
## 3. Center ↔ Guild 协议层
|
||||||
- [ ] 鉴权方案定稿(node token / HMAC)
|
- [x] 鉴权方案定稿(node token / HMAC)
|
||||||
- [ ] 注册握手协议文档化
|
- [x] 注册握手协议文档化
|
||||||
- [ ] 错误码与重试策略统一
|
- [x] 错误码与重试策略统一
|
||||||
- [ ] 版本协商(`X-Fabric-Version`)
|
- [x] 版本协商(`X-Fabric-Version`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 插件与扩展面(为 OpenclawPlugin 准备)
|
## 4. 插件与扩展面(为 OpenclawPlugin 准备)
|
||||||
- [ ] Webhook 事件信封落地(event_id/event_type/occurred_at/data)
|
- [x] Webhook 事件信封落地(event_id/event_type/occurred_at/data)
|
||||||
- [ ] HMAC 签名与重放防护
|
- [x] HMAC 签名与重放防护
|
||||||
- [ ] 出站重试队列(指数退避)
|
- [x] 出站重试队列(指数退避)
|
||||||
- [ ] Bot Token 入站调用鉴权
|
- [x] API Key 入站调用鉴权(不区分 Bot/人类)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 测试与质量门禁
|
## 5. 测试与质量门禁
|
||||||
|
|
||||||
### 5.1 自动化测试
|
### 5.1 自动化测试
|
||||||
- [ ] 单元测试(auth/service/message/seq)
|
- [x] 单元测试(auth/service/message/seq)
|
||||||
- [ ] 集成测试(MySQL + API)
|
- [x] 集成测试(MySQL + API)
|
||||||
- [ ] 合约测试(Center-Guild 协议)
|
- [ ] 合约测试(Center-Guild 协议)
|
||||||
|
|
||||||
### 5.2 质量门禁
|
### 5.2 质量门禁
|
||||||
- [ ] lint/typecheck/build 全绿
|
- [x] lint/typecheck/build 全绿
|
||||||
- [ ] API 文档(OpenAPI/Swagger)
|
- [x] API 文档(OpenAPI/Swagger)
|
||||||
- [ ] 关键链路压测(发送/拉取/回补)
|
- [ ] 关键链路压测(发送/拉取/回补)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
94
docs/center-guild-handshake-v1.md
Normal file
94
docs/center-guild-handshake-v1.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Center ↔ Guild 注册握手协议 v1(HMAC)
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
- 让 Guild Node 在注册时证明自己持有共享密钥(`CENTER_SHARED_SECRET`)
|
||||||
|
- 防止中间人篡改与重放攻击
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 注册接口
|
||||||
|
- **Method:** `POST`
|
||||||
|
- **Path:** `/api/nodes/register`
|
||||||
|
- **Content-Type:** `application/json`
|
||||||
|
|
||||||
|
### Body
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodeId": "guild-node-1",
|
||||||
|
"name": "Guild Node 1",
|
||||||
|
"endpoint": "http://guild-node-1:7002"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Headers
|
||||||
|
- `X-Fabric-Version`: 协议版本,当前固定为 `1`
|
||||||
|
- `X-Fabric-Timestamp`: ISO8601 UTC 时间(如 `2026-05-12T11:00:00.000Z`)
|
||||||
|
- `X-Fabric-Nonce`: 随机字符串(建议 UUID)
|
||||||
|
- `X-Fabric-Signature`: HMAC-SHA256 十六进制串
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Canonical String
|
||||||
|
签名输入格式(换行拼接):
|
||||||
|
|
||||||
|
```text
|
||||||
|
{METHOD}\n{PATH}\n{TIMESTAMP}\n{NONCE}\n{BODY_JSON}
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```text
|
||||||
|
POST
|
||||||
|
/api/nodes/register
|
||||||
|
2026-05-12T11:00:00.000Z
|
||||||
|
f8b3a8dc-3aeb-44fc-a4a1-36f8b6c27739
|
||||||
|
{"nodeId":"guild-node-1","name":"Guild Node 1","endpoint":"http://guild-node-1:7002"}
|
||||||
|
```
|
||||||
|
|
||||||
|
签名算法:
|
||||||
|
```text
|
||||||
|
signature = HMAC_SHA256_HEX(CENTER_SHARED_SECRET, canonicalString)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Center 侧校验规则
|
||||||
|
1. 必须包含三项头:`signature/timestamp/nonce`
|
||||||
|
2. `timestamp` 与服务端时间偏差不超过 5 分钟
|
||||||
|
3. 使用相同 canonical 规则重新计算签名
|
||||||
|
4. `timingSafeEqual` 比较签名
|
||||||
|
5. 签名通过后再做业务校验:
|
||||||
|
- nodeId 唯一
|
||||||
|
- endpoint 唯一
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 响应语义
|
||||||
|
### 成功
|
||||||
|
- `200 OK`
|
||||||
|
- body 含 `status: accepted` 与 node 信息
|
||||||
|
|
||||||
|
### 失败(建议)
|
||||||
|
- `403`:签名头缺失/签名错误/时间窗非法
|
||||||
|
- `409`:nodeId 或 endpoint 冲突
|
||||||
|
- `400`:参数非法
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 安全建议
|
||||||
|
- `CENTER_SHARED_SECRET` 长度至少 32 字节
|
||||||
|
- 定期轮换 secret(可采用双 key 过渡)
|
||||||
|
- 在网关层启用 HTTPS
|
||||||
|
- 后续可增加 nonce 去重表,防止时间窗内重放
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 版本协商(预留)
|
||||||
|
当前版本:`v1`
|
||||||
|
|
||||||
|
当前实现要求请求头:
|
||||||
|
- `X-Fabric-Version: 1`
|
||||||
|
|
||||||
|
若版本不匹配,Center 返回:
|
||||||
|
- `400`
|
||||||
|
- `error.code = FABRIC_VERSION_NOT_SUPPORTED`
|
||||||
|
- `supportedVersion = "1"`
|
||||||
75
docs/error-codes-and-retry-policy-v1.md
Normal file
75
docs/error-codes-and-retry-policy-v1.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Fabric 错误码与重试策略 v1
|
||||||
|
|
||||||
|
## 1) 统一错误响应结构
|
||||||
|
所有非 2xx 响应建议返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "FABRIC_XXX",
|
||||||
|
"message": "human readable",
|
||||||
|
"retryable": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
可选字段:
|
||||||
|
- `requestId`: 请求追踪 id
|
||||||
|
- `details`: 参数错误详情
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) 业务错误码(v1)
|
||||||
|
|
||||||
|
### 通用
|
||||||
|
- `FABRIC_BAD_REQUEST` → 400(参数不合法)
|
||||||
|
- `FABRIC_UNAUTHORIZED` → 401(认证失败)
|
||||||
|
- `FABRIC_FORBIDDEN` → 403(鉴权/签名失败)
|
||||||
|
- `FABRIC_NOT_FOUND` → 404(资源不存在)
|
||||||
|
- `FABRIC_CONFLICT` → 409(资源冲突/重复)
|
||||||
|
- `FABRIC_RATE_LIMITED` → 429(限流)
|
||||||
|
- `FABRIC_INTERNAL_ERROR` → 500(服务内部错误)
|
||||||
|
- `FABRIC_UNAVAILABLE` → 503(依赖不可用)
|
||||||
|
|
||||||
|
### Center↔Guild 协议
|
||||||
|
- `FABRIC_HMAC_MISSING_HEADERS` → 403
|
||||||
|
- `FABRIC_HMAC_INVALID_SIGNATURE` → 403
|
||||||
|
- `FABRIC_HMAC_TIMESTAMP_EXPIRED` → 403
|
||||||
|
- `FABRIC_NODE_ID_CONFLICT` → 409
|
||||||
|
- `FABRIC_NODE_ENDPOINT_CONFLICT` → 409
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
- `FABRIC_EDIT_WINDOW_EXPIRED` → 409
|
||||||
|
- `FABRIC_IDEMPOTENCY_REPLAY` → 200(命中幂等缓存,非错误)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) 重试策略(客户端/插件侧)
|
||||||
|
|
||||||
|
### 可重试(指数退避)
|
||||||
|
- HTTP: `429`, `500`, `502`, `503`, `504`
|
||||||
|
- 网络异常:超时/连接重置/临时 DNS 故障
|
||||||
|
|
||||||
|
### 不可重试
|
||||||
|
- HTTP: `400`, `401`, `403`, `404`, `409`(除非业务明确允许)
|
||||||
|
|
||||||
|
### 退避规则
|
||||||
|
- 基础间隔:1s
|
||||||
|
- 退避序列:1s / 2s / 4s / 8s / 16s
|
||||||
|
- 最大重试次数:5
|
||||||
|
- 加抖动:`±20%`
|
||||||
|
- 若有 `Retry-After`:优先按 `Retry-After`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) 幂等与重试配合
|
||||||
|
- 写接口重试必须带 `Idempotency-Key`
|
||||||
|
- 同 key 重放返回首次响应,避免重复写入
|
||||||
|
- 幂等记录建议至少保留 24h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) 服务端落地建议
|
||||||
|
- 网关/中间件统一异常映射为标准 `error.code`
|
||||||
|
- 在日志中记录:`error.code`、`requestId`、`status`
|
||||||
|
- 对 429/503 返回 `Retry-After`
|
||||||
Reference in New Issue
Block a user