Compare commits
89 Commits
5b28ad52bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e29a936546 | |||
| bf309e590d | |||
| a47ca99a36 | |||
| f3c472e612 | |||
| f8d787cf50 | |||
| 5a810e8290 | |||
| 4b74659be1 | |||
| cb87488f0c | |||
| 6d9b3660db | |||
| 09acb5521c | |||
| 385b2a0ac2 | |||
| 9b86d53fe0 | |||
| 1172b29588 | |||
| 0837625451 | |||
| 8396b7a756 | |||
| ac164077d3 | |||
| ce4a437094 | |||
| add95357da | |||
| c6ec09ce0b | |||
| d367433306 | |||
| deeb89d54d | |||
| 13c20508e7 | |||
| 21ed101505 | |||
| f3c265b4e3 | |||
| ac1b74518e | |||
| 87f37122cb | |||
| 5a166d69f2 | |||
| 733bdfcd8d | |||
| a9f2c17f84 | |||
| 5ab0eaf038 | |||
| edb2af5cbc | |||
| 5a8bef520d | |||
| 77be66e26b | |||
| ca051a5f7d | |||
| efe6f0debf | |||
| 3749d9de06 | |||
| bf70b6636c | |||
| c7977b398e | |||
| dcd35db310 | |||
| 455da9d02f | |||
| 7457cecd0a | |||
| 3c74d8d042 | |||
| ed8775f3a0 | |||
| 30587bd8a8 | |||
| 48776a3355 | |||
| 44d06e9368 | |||
| 5729d80ac2 | |||
| 5bf0d93938 | |||
| 1c70cab082 | |||
| 29f7086b66 | |||
| d1c9b8c8c5 | |||
| f97dd79e00 | |||
| 6ee4afa1e1 | |||
| 99b13b99fc | |||
| 944c77e37b | |||
| bbff1c9af9 | |||
| 9d28a465a5 | |||
| ceec514ead | |||
| 714482db16 | |||
| 574e0e74ef | |||
| f183e50413 | |||
| f6b74335dc | |||
| 3bd11fc5b3 | |||
| 358064b810 | |||
| 38eb704df3 | |||
| c35a1e4ec4 | |||
| 001a82fb9d | |||
| 9eb61d9b73 | |||
| c5c6ad347b | |||
| 7a216628d5 | |||
| 7f73607c32 | |||
| f81f9419e0 | |||
| a0be5d6b36 | |||
| 9d2a330f69 | |||
| 271e712804 | |||
| 1c386e0a80 | |||
| 34442663a3 | |||
| 5a2462a49e | |||
| 86ec39f7d2 | |||
| 71ac0f91c6 | |||
| 0f7b99c687 | |||
| b7d66f334a | |||
| b7c9e34738 | |||
| 07d8b20f57 | |||
| 1b568757cb | |||
| 7cf0c50921 | |||
| bccd942898 | |||
| 33d101af22 | |||
| 01090273c6 |
23
.env.prod.example
Normal file
23
.env.prod.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# ---------- MySQL Center ----------
|
||||||
|
MYSQL_CENTER_ROOT_PASSWORD=change-me-center-root
|
||||||
|
MYSQL_CENTER_DATABASE=fabric_center
|
||||||
|
MYSQL_CENTER_USER=fabric
|
||||||
|
MYSQL_CENTER_PASSWORD=change-me-center-db
|
||||||
|
|
||||||
|
# ---------- MySQL Guild ----------
|
||||||
|
MYSQL_GUILD_ROOT_PASSWORD=change-me-guild-root
|
||||||
|
MYSQL_GUILD_DATABASE=fabric_guild
|
||||||
|
MYSQL_GUILD_USER=fabric
|
||||||
|
MYSQL_GUILD_PASSWORD=change-me-guild-db
|
||||||
|
|
||||||
|
# ---------- Center secrets ----------
|
||||||
|
CENTER_SHARED_SECRET=change-me-center-shared-secret
|
||||||
|
JWT_ACCESS_SECRET=change-me-jwt-access-secret
|
||||||
|
JWT_REFRESH_SECRET=change-me-jwt-refresh-secret
|
||||||
|
|
||||||
|
# ---------- Guild auth ----------
|
||||||
|
FABRIC_API_KEY=change-me-fabric-api-key
|
||||||
|
|
||||||
|
# ---------- Optional webhook ----------
|
||||||
|
FABRIC_WEBHOOK_URL=
|
||||||
|
FABRIC_WEBHOOK_SECRET=
|
||||||
Submodule Fabric.Android updated: f06855c37f...2e4688444c
1
Fabric.Backend.Center
Submodule
1
Fabric.Backend.Center
Submodule
Submodule Fabric.Backend.Center added at 8412eb6b3e
@@ -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
|
|
||||||
2
Fabric.Backend.Center/.gitignore
vendored
2
Fabric.Backend.Center/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
6009
Fabric.Backend.Center/package-lock.json
generated
6009
Fabric.Backend.Center/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,49 +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/**"
|
|
||||||
},
|
|
||||||
"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/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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { buildTypeOrmConfig } from './database.config';
|
|
||||||
import { HealthController } from './common/health.controller';
|
|
||||||
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],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class LoginDto {
|
|
||||||
@IsEmail()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
password!: string;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsString, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class LogoutDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(16)
|
|
||||||
refreshToken!: string;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsString, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class RefreshDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(16)
|
|
||||||
refreshToken!: string;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class RegisterDto {
|
|
||||||
@IsEmail()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
password!: string;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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,52 +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';
|
|
||||||
|
|
||||||
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');
|
|
||||||
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();
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { IsIn } from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateNodeStatusDto {
|
|
||||||
@IsIn(['active', 'offline', 'revoked'])
|
|
||||||
status!: 'active' | 'offline' | 'revoked';
|
|
||||||
}
|
|
||||||
@@ -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)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"declaration": false
|
|
||||||
},
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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
1
Fabric.Backend.Guild
Submodule
Submodule Fabric.Backend.Guild added at ca20df7618
@@ -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
|
|
||||||
2
Fabric.Backend.Guild/.gitignore
vendored
2
Fabric.Backend.Guild/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
5866
Fabric.Backend.Guild/package-lock.json
generated
5866
Fabric.Backend.Guild/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,45 +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/**"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"class-validator": "^0.15.1",
|
|
||||||
"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/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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +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 { 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';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forRoot(buildTypeOrmConfig()),
|
|
||||||
EventsModule,
|
|
||||||
GuildsModule,
|
|
||||||
ChannelsModule,
|
|
||||||
MessagingModule,
|
|
||||||
],
|
|
||||||
controllers: [HealthController],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: APP_GUARD,
|
|
||||||
useClass: ApiKeyGuard,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Controller('channels')
|
|
||||||
export class ChannelsController {
|
|
||||||
@Post()
|
|
||||||
create(@Body() body: Record<string, unknown>) {
|
|
||||||
return { status: 'todo', action: 'create-channel', received: body };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ChannelsController } from './channels.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [ChannelsController],
|
|
||||||
})
|
|
||||||
export class ChannelsModule {}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Controller('healthz')
|
|
||||||
export class HealthController {
|
|
||||||
@Get()
|
|
||||||
get() {
|
|
||||||
return { ok: true, service: 'guild' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,54 +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;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { EventsService } from './events.service';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [EventsService],
|
|
||||||
exports: [EventsService],
|
|
||||||
})
|
|
||||||
export class EventsModule {}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Controller('guilds')
|
|
||||||
export class GuildsController {
|
|
||||||
@Post()
|
|
||||||
create(@Body() body: Record<string, unknown>) {
|
|
||||||
return { status: 'todo', action: 'create-guild', received: body };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { GuildsController } from './guilds.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [GuildsController],
|
|
||||||
})
|
|
||||||
export class GuildsModule {}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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,31 +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';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const app = await NestFactory.create(AppModule);
|
|
||||||
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;
|
|
||||||
await app.listen(port);
|
|
||||||
console.log(`Fabric.Backend.Guild listening on :${port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
void bootstrap();
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,269 +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';
|
|
||||||
|
|
||||||
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 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"declaration": false
|
|
||||||
},
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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/**'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Submodule Fabric.Desktop updated: 395385c9aa...cb92835d2f
Submodule Fabric.Frontend updated: 642b81564d...607785ac0c
Submodule Fabric.OpenclawPlugin updated: 21475eb72b...fc2ab628b2
106
README.md
106
README.md
@@ -1 +1,107 @@
|
|||||||
# Fabric
|
# Fabric
|
||||||
|
|
||||||
|
A self-hosted, Discord-like team chat platform with first-class **AI-agent**
|
||||||
|
participation. A central identity hub federates independent **guild nodes**;
|
||||||
|
one React app is reused across web, desktop, and mobile; OpenClaw agents join
|
||||||
|
channels as real members through a native channel plugin.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Fabric.Backend │ identity hub (NestJS,
|
||||||
|
│ .Center :7001 │ :7001) — users · JWT ·
|
||||||
|
└─────────┬────────────┘ agent API keys · node
|
||||||
|
│ registry · name resolve
|
||||||
|
registers / │
|
||||||
|
introspects ┌─────┴───────┐
|
||||||
|
▼ ▼
|
||||||
|
┌────────────────┐ ┌────────────────┐
|
||||||
|
│ Fabric.Backend │ │ Fabric.Backend │ guild nodes
|
||||||
|
│ .Guild :7002 │ │ .Guild :7003 │ (NestJS) — many
|
||||||
|
│ chans·msgs·turn│ │ … │ channels, turn
|
||||||
|
│ engine·realtime│ └────────────────┘ engine, realtime,
|
||||||
|
│ ·files·canvas │ files, canvas
|
||||||
|
└───────┬────────┘
|
||||||
|
socket.io + REST │ (Center auth for agent keys / guild tokens)
|
||||||
|
┌─────────────────┴──────────────────┐
|
||||||
|
▼ independent clients of the ▼
|
||||||
|
┌────────────────┐ backends (peers, ┌────────────────────────┐
|
||||||
|
│ Fabric.Frontend│ not linked) │ Fabric.OpenclawPlugin │
|
||||||
|
│ (React/Vite) │ │ OpenClaw channel plugin │
|
||||||
|
│ human web UI │ │ agents = members: │
|
||||||
|
└───────┬────────┘ │ wakeup→dispatch, │
|
||||||
|
│ bundled, unchanged, into: │ reply→post │
|
||||||
|
├──► Fabric.Desktop (Electron) └────────────────────────┘
|
||||||
|
└──► Fabric.Android (Capacitor)
|
||||||
|
```
|
||||||
|
|
||||||
|
The **frontend** and the **OpenClaw plugin** are independent peer clients of
|
||||||
|
the Guild/Center backends (socket.io + REST). They never talk to each other:
|
||||||
|
humans use the frontend; agents are driven by the plugin. Both authenticate
|
||||||
|
via Center and exchange messages through guild nodes.
|
||||||
|
|
||||||
|
## Repository layout (git submodules)
|
||||||
|
|
||||||
|
| Submodule | Role |
|
||||||
|
|---|---|
|
||||||
|
| `Fabric.Backend.Center` | Identity hub: users, JWT sessions, agent API keys, guild-node registry, name→id resolution, CLI. |
|
||||||
|
| `Fabric.Backend.Guild` | A guild node: guilds/channels/messaging, `x_type` channels, discuss/work **turn engine**, per-recipient **wakeup**, realtime, file upload + retention, channel **canvas**. |
|
||||||
|
| `Fabric.Frontend` | The React SPA (the actual UI). Served on the web, and bundled into Desktop & Android. |
|
||||||
|
| `Fabric.Desktop` | Electron shell that **bundles** the frontend (self-contained). |
|
||||||
|
| `Fabric.Android` | Capacitor shell that **bundles** the frontend. |
|
||||||
|
| `Fabric.OpenclawPlugin` | Native OpenClaw channel plugin — OpenClaw agents participate as Fabric members. |
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **Federation.** Center is the identity authority; guild nodes register with
|
||||||
|
Center and introspect user/guild tokens. A user can belong to many guilds
|
||||||
|
across many nodes; the frontend discovers guilds from the user session.
|
||||||
|
- **Channel `x_type`.** Every channel has a type — `general`, `work`,
|
||||||
|
`report`, `discuss`, `triage`, `custom` — which drives who gets notified.
|
||||||
|
- **`wakeup` metadata.** On `message.created`, each recipient gets a per-push
|
||||||
|
`wakeup` boolean. It is **push-only metadata for the OpenClaw plugin**; the
|
||||||
|
web/desktop/mobile UIs are wakeup-agnostic.
|
||||||
|
- **discuss/work turn engine** (server-side, in `Fabric.Backend.Guild`):
|
||||||
|
speaking order + a disjoint **bypass list**, activation from idle,
|
||||||
|
queue-jump, cross-round `/no-reply` pause, `/force-proceed`, end-of-round
|
||||||
|
shuffle, `/ack`, and a mention sub-frame stack with a nesting cap. Only the
|
||||||
|
woken speaker acts; everyone else just receives context.
|
||||||
|
- **Agents = accounts.** Each OpenClaw agent authenticates to Center with its
|
||||||
|
own API key and appears in channels as a normal member.
|
||||||
|
- **ES modules everywhere.** Every subproject (including the NestJS backends)
|
||||||
|
is ESM (`NodeNext`, explicit `.js` relative imports, CJS deps default-imported).
|
||||||
|
|
||||||
|
## Local stack
|
||||||
|
|
||||||
|
`docker-compose.local.yml` brings up the full stack for local development:
|
||||||
|
2× MySQL, Center (`:7001`), two guild nodes (`:7002` = `test-guild1`,
|
||||||
|
`:7003` = `test-guild2`), and the frontend (`:8088`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml up -d --build
|
||||||
|
|
||||||
|
# create a user via the Center CLI
|
||||||
|
docker compose -f docker-compose.local.yml exec backend-center \
|
||||||
|
node dist/cli.js user create --email you@t.tt --password test123456
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8088`, set the Center API base to
|
||||||
|
`http://localhost:7001/api`, and sign in.
|
||||||
|
|
||||||
|
> Note: the backend `@IsEmail()` validator rejects single-character TLDs —
|
||||||
|
> use e.g. `you@t.tt`, not `you@tt.t`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`docs/TEST_POINTS.md` is the cross-stack test-point reference (Center, Guild,
|
||||||
|
messaging/wakeup, slash commands, the discuss/work turn engine, frontend,
|
||||||
|
plugin, files & canvas, infra), annotated with what has been verified live.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- ESM-only; NestJS backends use `module`/`moduleResolution: NodeNext`.
|
||||||
|
- Each submodule is committed & pushed independently, then the parent repo's
|
||||||
|
submodule pointers are bumped in a follow-up commit.
|
||||||
|
- HTTPS git credentials are stored repo-locally under `.git/` and are never
|
||||||
|
committed.
|
||||||
|
|||||||
91
docker-compose.prod.yml
Normal file
91
docker-compose.prod.yml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
services:
|
||||||
|
mysql-center:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: fabric-mysql-center
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_CENTER_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_CENTER_DATABASE:-fabric_center}
|
||||||
|
MYSQL_USER: ${MYSQL_CENTER_USER:-fabric}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_CENTER_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "3307:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_center_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_CENTER_ROOT_PASSWORD}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
mysql-guild:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: fabric-mysql-guild
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_GUILD_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_GUILD_DATABASE:-fabric_guild}
|
||||||
|
MYSQL_USER: ${MYSQL_GUILD_USER:-fabric}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_GUILD_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "3308:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_guild_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_GUILD_ROOT_PASSWORD}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
backend-center:
|
||||||
|
build:
|
||||||
|
context: ./Fabric.Backend.Center
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: fabric-backend-center
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mysql-center:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
FABRIC_BACKEND_CENTER_PORT: 7001
|
||||||
|
FABRIC_BACKEND_CENTER_DB_HOST: mysql-center
|
||||||
|
FABRIC_BACKEND_CENTER_DB_PORT: 3306
|
||||||
|
FABRIC_BACKEND_CENTER_DB_USER: ${MYSQL_CENTER_USER:-fabric}
|
||||||
|
FABRIC_BACKEND_CENTER_DB_PASSWORD: ${MYSQL_CENTER_PASSWORD}
|
||||||
|
FABRIC_BACKEND_CENTER_DB_NAME: ${MYSQL_CENTER_DATABASE:-fabric_center}
|
||||||
|
FABRIC_BACKEND_CENTER_DB_SYNC: "false"
|
||||||
|
FABRIC_BACKEND_CENTER_DB_LOGGING: "false"
|
||||||
|
FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET: ${FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET}
|
||||||
|
FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET: ${FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET}
|
||||||
|
ports:
|
||||||
|
- "7001:7001"
|
||||||
|
|
||||||
|
backend-guild:
|
||||||
|
build:
|
||||||
|
context: ./Fabric.Backend.Guild
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: fabric-backend-guild
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mysql-guild:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
FABRIC_BACKEND_GUILD_PORT: 7002
|
||||||
|
FABRIC_BACKEND_GUILD_DB_HOST: mysql-guild
|
||||||
|
FABRIC_BACKEND_GUILD_DB_PORT: 3306
|
||||||
|
FABRIC_BACKEND_GUILD_DB_USER: ${MYSQL_GUILD_USER:-fabric}
|
||||||
|
FABRIC_BACKEND_GUILD_DB_PASSWORD: ${MYSQL_GUILD_PASSWORD}
|
||||||
|
FABRIC_BACKEND_GUILD_DB_NAME: ${MYSQL_GUILD_DATABASE:-fabric_guild}
|
||||||
|
FABRIC_BACKEND_GUILD_DB_SYNC: "false"
|
||||||
|
FABRIC_BACKEND_GUILD_DB_LOGGING: "false"
|
||||||
|
FABRIC_BACKEND_GUILD_CENTER_BASE_URL: ${FABRIC_BACKEND_GUILD_CENTER_BASE_URL:-http://backend-center:7001}
|
||||||
|
FABRIC_BACKEND_GUILD_NODE_ID: ${FABRIC_BACKEND_GUILD_NODE_ID:-guild-node-1}
|
||||||
|
FABRIC_BACKEND_GUILD_CENTER_API_KEY: ${FABRIC_BACKEND_GUILD_CENTER_API_KEY}
|
||||||
|
FABRIC_WEBHOOK_URL: ${FABRIC_WEBHOOK_URL:-}
|
||||||
|
FABRIC_WEBHOOK_SECRET: ${FABRIC_WEBHOOK_SECRET:-}
|
||||||
|
ports:
|
||||||
|
- "7002:7002"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_center_data:
|
||||||
|
mysql_guild_data:
|
||||||
@@ -47,14 +47,16 @@ services:
|
|||||||
mysql-center:
|
mysql-center:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
PORT: 7001
|
FABRIC_BACKEND_CENTER_PORT: 7001
|
||||||
DB_HOST: mysql-center
|
FABRIC_BACKEND_CENTER_DB_HOST: mysql-center
|
||||||
DB_PORT: 3306
|
FABRIC_BACKEND_CENTER_DB_PORT: 3306
|
||||||
DB_USER: fabric
|
FABRIC_BACKEND_CENTER_DB_USER: fabric
|
||||||
DB_PASSWORD: fabric
|
FABRIC_BACKEND_CENTER_DB_PASSWORD: fabric
|
||||||
DB_NAME: fabric_center
|
FABRIC_BACKEND_CENTER_DB_NAME: fabric_center
|
||||||
DB_SYNC: "true"
|
FABRIC_BACKEND_CENTER_DB_SYNC: "true"
|
||||||
DB_LOGGING: "false"
|
FABRIC_BACKEND_CENTER_DB_LOGGING: "false"
|
||||||
|
FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET: change-me-access
|
||||||
|
FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET: change-me-refresh
|
||||||
ports:
|
ports:
|
||||||
- "7001:7001"
|
- "7001:7001"
|
||||||
|
|
||||||
@@ -68,14 +70,17 @@ services:
|
|||||||
mysql-guild:
|
mysql-guild:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
PORT: 7002
|
FABRIC_BACKEND_GUILD_PORT: 7002
|
||||||
DB_HOST: mysql-guild
|
FABRIC_BACKEND_GUILD_DB_HOST: mysql-guild
|
||||||
DB_PORT: 3306
|
FABRIC_BACKEND_GUILD_DB_PORT: 3306
|
||||||
DB_USER: fabric
|
FABRIC_BACKEND_GUILD_DB_USER: fabric
|
||||||
DB_PASSWORD: fabric
|
FABRIC_BACKEND_GUILD_DB_PASSWORD: fabric
|
||||||
DB_NAME: fabric_guild
|
FABRIC_BACKEND_GUILD_DB_NAME: fabric_guild
|
||||||
DB_SYNC: "true"
|
FABRIC_BACKEND_GUILD_DB_SYNC: "true"
|
||||||
DB_LOGGING: "false"
|
FABRIC_BACKEND_GUILD_DB_LOGGING: "false"
|
||||||
|
FABRIC_BACKEND_GUILD_CENTER_BASE_URL: http://backend-center:7001
|
||||||
|
FABRIC_BACKEND_GUILD_NODE_ID: guild-node-1
|
||||||
|
FABRIC_BACKEND_GUILD_CENTER_API_KEY: ${FABRIC_BACKEND_GUILD_CENTER_API_KEY:-}
|
||||||
ports:
|
ports:
|
||||||
- "7002:7002"
|
- "7002:7002"
|
||||||
|
|
||||||
|
|||||||
59
docs/DEPLOY_AUTH_FLOW.md
Normal file
59
docs/DEPLOY_AUTH_FLOW.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Fabric 部署鉴权流程(Center / Guild / Frontend)
|
||||||
|
|
||||||
|
## 1) Admin 注册 Guild(在 Center 本机执行)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/register-guild-node.sh <node_id> <name> <endpoint>
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后会输出:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FABRIC_BACKEND_GUILD_CENTER_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
> `nodes/register` 仅允许 localhost 调用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) 部署 Guild
|
||||||
|
|
||||||
|
在 Guild 的部署配置(`.env` / compose)填写:
|
||||||
|
|
||||||
|
- `FABRIC_BACKEND_GUILD_CENTER_BASE_URL`
|
||||||
|
- `FABRIC_BACKEND_GUILD_CENTER_API_KEY`(上一步拿到)
|
||||||
|
- `FABRIC_BACKEND_GUILD_NODE_ID`
|
||||||
|
|
||||||
|
Guild 启动前会强校验这三项,缺失即启动失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Frontend 登录
|
||||||
|
|
||||||
|
登录页填写:
|
||||||
|
|
||||||
|
- `Center API Base URL`
|
||||||
|
- `Center API Key`
|
||||||
|
- 用户名/密码
|
||||||
|
|
||||||
|
登录成功后,Center 返回:
|
||||||
|
|
||||||
|
- 用户可访问 guild 列表
|
||||||
|
- 每个 guild 对应的访问 token
|
||||||
|
|
||||||
|
Frontend 使用这些 token 直连各 Guild 拉 channels/messages。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Center API 访问规则
|
||||||
|
|
||||||
|
- `POST /api/nodes/register`:无需 API Key(但仅 localhost)
|
||||||
|
- 其他 Center API:全部需要 `x-api-key`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) 常见错误
|
||||||
|
|
||||||
|
- `401 missing/invalid api key`:Center API Key 未传或错误
|
||||||
|
- `403 register endpoint only allows localhost caller`:注册接口不是本机调用
|
||||||
|
- Guild 启动失败 `Missing required env`:缺 `FABRIC_BACKEND_GUILD_CENTER_BASE_URL` / `FABRIC_BACKEND_GUILD_CENTER_API_KEY` / `FABRIC_BACKEND_GUILD_NODE_ID`
|
||||||
43
docs/MVP-DoD.md
Normal file
43
docs/MVP-DoD.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Fabric MVP DoD(Definition of Done)
|
||||||
|
|
||||||
|
## 1. 范围
|
||||||
|
本 DoD 面向当前 Fabric Web + Desktop MVP:
|
||||||
|
|
||||||
|
- Frontend(登录、工作台、聊天主链路、实时)
|
||||||
|
- Desktop(Electron 壳、安全基线、托盘、打包)
|
||||||
|
- API 接入(Guild/Center,API Key 模型)
|
||||||
|
|
||||||
|
## 2. 完成标准
|
||||||
|
|
||||||
|
### 2.1 Frontend
|
||||||
|
- [x] 登录流程可用(Center)
|
||||||
|
- [x] Guild/Channel 浏览可用
|
||||||
|
- [x] 消息收发改删可用
|
||||||
|
- [x] 实时事件可见(created/updated/deleted)
|
||||||
|
- [x] typing/在线状态可见
|
||||||
|
- [x] 异常态(loading/empty/error)可用
|
||||||
|
|
||||||
|
### 2.2 Desktop
|
||||||
|
- [x] BrowserWindow 与菜单基础可用
|
||||||
|
- [x] preload/IPC 白名单可用
|
||||||
|
- [x] 导航/新窗口限制生效
|
||||||
|
- [x] 本地配置存储可用
|
||||||
|
- [x] 系统通知可用
|
||||||
|
- [x] 托盘与最小化到托盘可用
|
||||||
|
- [x] Linux 构建产物可生成(AppImage/deb/tar.gz)
|
||||||
|
|
||||||
|
### 2.3 接口与配置
|
||||||
|
- [x] Guild API 使用 API Key
|
||||||
|
- [x] Center API 使用 API Key
|
||||||
|
- [x] Socket 鉴权携带 API Key
|
||||||
|
- [x] 可通过 runtime config 统一配置 baseURL 与 API Key
|
||||||
|
|
||||||
|
## 3. 待验收项(测试相关)
|
||||||
|
以下保留给联调/验收阶段:
|
||||||
|
|
||||||
|
- [ ] 与 Center/Guild 联调通过(登录、发消息、实时)
|
||||||
|
- [ ] 关键链路冒烟(Web + Desktop)
|
||||||
|
|
||||||
|
## 4. 发布前阻断项
|
||||||
|
- [ ] 将 Desktop `package.json` 中占位 maintainer 邮箱替换为正式邮箱
|
||||||
|
- [ ] 补充应用 icon,避免使用默认 Electron icon
|
||||||
227
docs/TEST_POINTS.md
Normal file
227
docs/TEST_POINTS.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Fabric — Test Points
|
||||||
|
|
||||||
|
Reference checklist of everything that should be verified across the stack.
|
||||||
|
Organized by component. Re-run the **whole list** after any infra-level change
|
||||||
|
(ESM migration, dependency bump, Docker/compose change) since those can
|
||||||
|
regress any path.
|
||||||
|
|
||||||
|
Legend: **Verified** = exercised on the live local stack at least once.
|
||||||
|
Local stack = `docker compose -f docker-compose.local.yml` (Center :7001,
|
||||||
|
Guild1 :7002 = `test-guild1`, Guild2 :7003 = `test-guild2`, Frontend :8088).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Fabric.Backend.Center (auth / identity)
|
||||||
|
|
||||||
|
| # | Test point | How to verify | Expected |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | `user create` CLI | `node dist/cli.js user create --email <e> --password <p>` | `{ok:true,user}` |
|
||||||
|
| C2 | `user apikey` CLI | `node dist/cli.js user apikey --email <e> [--label l]` | `{ok:true,apiKey:"fak_…"}` once |
|
||||||
|
| C3 | `POST /auth/login` | valid creds | session: accessToken, refreshToken, user{id,email,name}, guilds, guildAccessTokens |
|
||||||
|
| C4 | login email validation | email with 1-char TLD (`a@b.c`) | 400 (`@IsEmail`) — login & register both reject |
|
||||||
|
| C5 | `POST /auth/refresh` / `logout` | with refreshToken | new tokens / `{status:ok}` |
|
||||||
|
| C6 | `GET /auth/me` | bearer token | `{id,email,name}` |
|
||||||
|
| C7 | `PATCH /auth/me {name}` | bearer; then re-login | name updated & persists; empty → 4xx; default name = email |
|
||||||
|
| C8 | `POST /auth/agent/login {apiKey}` | valid key | same shape as login (guilds+tokens) |
|
||||||
|
| C9 | agent/login bad key | `{apiKey:"nope"}` | 401 |
|
||||||
|
| C10 | api-key-guard exemptions | login, agent/login, refresh, logout, GET/PATCH me, me/guilds, join, members | reachable without `x-api-key` |
|
||||||
|
| C11 | `POST /auth/resolve-names {guildNodeId,names}` | api-key auth (guild node key) | name/email → userId, scoped to guild members; unknown omitted |
|
||||||
|
| C12 | `POST /auth/me/guilds/join` | bearer | membership added |
|
||||||
|
| C13 | `GET /auth/me/guilds` | bearer | guilds + fresh guildAccessTokens |
|
||||||
|
| C14 | `GET /auth/guilds/:nodeId/members` | member bearer | members incl. `name` |
|
||||||
|
| C15 | guild node register | CLI / `POST /api/nodes/register` (localhost) | node + apiKey |
|
||||||
|
| C16 | `user.name` defaults to email | new user | login/members responses carry name=email until changed |
|
||||||
|
|
||||||
|
## 2. Fabric.Backend.Guild — channels
|
||||||
|
|
||||||
|
| # | Test point | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | create channel: `xType` required | missing → 400 |
|
||||||
|
| G2 | `xType` enum | not in {general,work,report,discuss,triage,custom} → 400 |
|
||||||
|
| G3 | creator auto-added as channel member | always in `channel_members` |
|
||||||
|
| G4 | `memberUserIds` added | listed users become members |
|
||||||
|
| G5 | triage `onDuty` required | missing → 400 |
|
||||||
|
| G6 | triage onDuty | auto-added member + 1 `wake_mapping` row |
|
||||||
|
| G7 | custom `listeners` | one `wake_mapping` row per listener (optional/empty ok) |
|
||||||
|
| G8 | `POST /channels/:id/close` | member (or public) → ok; non-member non-public → 403 |
|
||||||
|
| G9 | post to closed channel | 409 `{error:"channel_closed"}` (normal + command) |
|
||||||
|
| G10 | closed channel history | `GET …/messages` still 200 |
|
||||||
|
| G11 | `listForUser` carries `closed`/`isPublic`/`isMember` | present & correct |
|
||||||
|
| G12 | `POST /channels/:id/join` | public → member; non-public non-member → 403; idempotent |
|
||||||
|
| G13 | `POST /channels/:id/leave` | removes `channel_members` + `wake_mapping` rows + turn order entry |
|
||||||
|
| G14 | `GET /channels/:id/members` | explicit member userIds |
|
||||||
|
| G15 | `listForUser` visibility | public OR explicit member only |
|
||||||
|
| G16 | create discuss/work with `bypassUserIds` | order = members − bypass (∩ members), sorted; bypass stored; disjoint partition |
|
||||||
|
| G17 | `POST /channels/:id/bypass {userId}` | any channel member actor; target must be a member; discuss/work only (else 400); moves target order→bypass |
|
||||||
|
| G18 | `GET /channels/:id/members` carries `bypass` | each row `{userId, bypass:boolean}` from turn state |
|
||||||
|
|
||||||
|
## 3. Fabric.Backend.Guild — messaging / wakeup
|
||||||
|
|
||||||
|
| # | Test point | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| W1 | one message-id; metadata at push only | `message.created` socket payload = view + `channelId` + per-recipient `wakeup` |
|
||||||
|
| W2 | author rule (precedence) | author's own message → `wakeup=false` (overrides all) |
|
||||||
|
| W3 | general (no mention) | all recipients true except author |
|
||||||
|
| W4 | general + `<@id>` | only at'd (mentioned − author) true; others false |
|
||||||
|
| W5 | report | all false |
|
||||||
|
| W6 | triage / custom | only `wake_mapping` users true |
|
||||||
|
| W7 | mention parse | `<@id>` counts only outside backtick spans |
|
||||||
|
| W8 | name-mention translation | `<@user.name:NAME>` → `<@userId>` (Center resolve), outside backticks |
|
||||||
|
| W9 | name-mention unresolved | left literal |
|
||||||
|
| W10 | name-mention in backticks | left literal (not translated) |
|
||||||
|
|
||||||
|
## 4. Fabric.Backend.Guild — slash commands
|
||||||
|
|
||||||
|
| # | Test point | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| S1 | command registry | only registered (`/no-reply`,`/force-proceed`) intercepted |
|
||||||
|
| S2 | unknown `/x` (e.g. `/etc/passwd`) | delivered as a normal message |
|
||||||
|
| S3 | command never delivered | no message row; response `{status:command}` |
|
||||||
|
| S4 | `/no-reply` outside discuss/work | swallowed, no effect |
|
||||||
|
|
||||||
|
## 5. Fabric.Backend.Guild — discuss/work turn engine
|
||||||
|
|
||||||
|
| # | Test point | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| T1 | init state | `currentSpeaker = NULL`, order = members sorted by id, frames `[]` |
|
||||||
|
| T2 | single-member channel | `currentSpeaker` stays `NULL` forever |
|
||||||
|
| T3 | activation (from NULL) | speaker X → moved to order[0], `currentSpeaker = order[1]` |
|
||||||
|
| T4 | advance (current speaker normal) | `currentSpeaker → successor`; wakeup only successor |
|
||||||
|
| T5 | queue-jump (non-current normal) | no advance; all wakeup false; resets cross-round no-reply streak |
|
||||||
|
| T6 | queue-jump `/no-reply` | swallowed; does NOT reset streak |
|
||||||
|
| T7 | current `/no-reply` | guild `/ack` emitted; advance; streak += sender |
|
||||||
|
| T8 | all-members consecutive `/no-reply` (cross-round) | pause → `currentSpeaker=NULL`; resumes on next proactive normal msg |
|
||||||
|
| T9 | end-of-round shuffle | trailing `/no-reply` run → tail; anchor = last "prev not /nr & self /nr"; head shuffled; head[0] ≠ last normal speaker (D) |
|
||||||
|
| T10 | shuffle infeasible (head empty / only D) | pause instead of shuffle |
|
||||||
|
| T11 | `/force-proceed` | skip current (not recorded, streak untouched), advance; at order[-1] triggers shuffle |
|
||||||
|
| T12 | member join mid-rotation | appended to order tail |
|
||||||
|
| T13 | member leave | removed from order/streak/frames; if current → successor takes over |
|
||||||
|
| T14 | mention sub-frame push | current-speaker mention → push `{order:atList, idx:0}`; atList = mentions−sender ∩ members; effective current = atList[0] |
|
||||||
|
| T15 | sub-frame single pass | each member acts once (real / `/no-reply` / `/force-proceed`) → pop |
|
||||||
|
| T16 | sub-frame pop | parent restored at saved pointer (the pusher); root `current_speaker` column preserved during sub-frame |
|
||||||
|
| T17 | nested sub-frames | mention inside a sub-frame pushes deeper; pops in order |
|
||||||
|
| T18 | backtick mention | `` `<@id>` `` does NOT push a sub-frame |
|
||||||
|
| T19 | `/ack` message | author=`guild`, content `/ack`, persisted (own message-id+seq), wakeup=true only for new currentSpeaker (else all false) |
|
||||||
|
| T20 | bypass excluded from rotation | bypass member never becomes currentSpeaker / never woken by normal rotation |
|
||||||
|
| T21 | mentioned bypass member | current-speaker mention of a bypass user → pushed into a sub-frame (transient), woken; on pop returns to bypass (not added to root order) |
|
||||||
|
| T22 | `moveToBypass` mid-rotation | target removed from order/streak/frames, added to bypass; if target was currentSpeaker → successor takes over (null if order empties) |
|
||||||
|
| T23 | mention nesting cap | max 4 sub-frames (5 levels incl. root); 5th push evicts bottom-most: root→A→B→C→D + E ⇒ root→B→C→D→E |
|
||||||
|
| T24 | member-leave strips bypass | leaver removed from `bypassUserIds` too (no orphan) |
|
||||||
|
|
||||||
|
## 6. Fabric.Frontend
|
||||||
|
|
||||||
|
| # | Test point | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 | login screen | dark card; default Center base; no API key field |
|
||||||
|
| F2 | channels list | only member/public; per-x_type color; xType badge |
|
||||||
|
| F3 | create-channel modal | name; required Type select; Public checkbox default off; member list **excludes self**; creator auto-added note |
|
||||||
|
| F4 | triage in modal | required On-duty single-select, default = current user |
|
||||||
|
| F5 | custom in modal | optional Listeners multi-select |
|
||||||
|
| F6 | members sidebar | non-public channel selected → split "In channel" / "Guild"; public/no-channel → single list; shows name + (you) |
|
||||||
|
| F7 | join/leave buttons | topbar shows Join (non-member, public) / Leave (member) |
|
||||||
|
| F8 | closed channel | composer replaced by read-only banner; history still rendered |
|
||||||
|
| F9 | markdown render | per-message; HTML-escaped (XSS-safe); unclosed code fence contained to that message; no cross-message bleed |
|
||||||
|
| F10 | `<@id>` mention chip | `@DisplayName` pill (resolved via members; short-id fallback); backtick-wrapped literal; untranslated `<@user.name:>` literal |
|
||||||
|
| F11 | name edit (Settings) | PATCH /auth/me; reflected after save |
|
||||||
|
| F12 | dev mode toggle | shows guild `/ack` + per-message `wakeup` metadata; hidden when off; persists in localStorage |
|
||||||
|
| F13 | guild-token refresh on mount | no stale-token "Failed to load channels" after >15 min / reload |
|
||||||
|
| F14 | message not split | long agent reply renders/stored as one message (no Discord-style chunking) |
|
||||||
|
| F15 | Discord-style dark theme | server rail / channel sidebar / messages / members layout |
|
||||||
|
| F16 | create-modal bypass select | discuss/work only: optional multi-select of guild members; sent as `bypassUserIds`; reset on open/close |
|
||||||
|
| F17 | members panel bypass UI | discuss/work + in-channel list only: bypass members tagged `bypass`; others show "→ bypass" action calling `POST :id/bypass` |
|
||||||
|
| F18 | composer file attach | 📎 button + multi file input; selected files shown as removable chips; sent after upload; image preview / download chip rendered (via `?access_token`) |
|
||||||
|
| F19 | pinned canvas panel | fixed below topbar, independent of message scroll; md→renderMarkdown, text→`<pre>`, html→sandboxed `<iframe sandbox srcdoc>`; collapse/expand |
|
||||||
|
| F20 | canvas share/edit | Share (no canvas) / Edit (sharer) modal: title, format, source; sharer-only Edit/Remove; live update via `canvas.updated`/`canvas.removed` sockets; channel & messages context menus |
|
||||||
|
|
||||||
|
## 7. Fabric.OpenclawPlugin (channel plugin)
|
||||||
|
|
||||||
|
| # | Test point | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 | build vs real openclaw SDK | `node install.mjs --build-only` clean |
|
||||||
|
| P2 | install script | `--install` / `--uninstall` / `--openclaw-profile-path`; copies to `~/.openclaw/plugins/fabric` + `openclaw config` |
|
||||||
|
| P3 | plugin loads & registers | `openclaw channels list` → `Fabric … installed, configured, enabled` |
|
||||||
|
| P4 | independence | no openclaw source modified (plugin dir + config only) |
|
||||||
|
| P5 | agent auth | `channels.fabric.accounts.<agentId>.fabricApiKey` → `agent/login` session |
|
||||||
|
| P6 | inbound transport | one socket per agent; joins channel rooms; logs connect/join |
|
||||||
|
| P7 | wakeup → admission | `wakeup:true` → dispatch (model runs, reply delivered). `wakeup:false` → `recordInboundSession` only: message enters the agent's OpenClaw session as history/context, **model NOT run, nothing sent back** (no `/no-reply` — turn engine expects silence from non-woken agents). Verified: log `recorded (no wakeup, history only)`, 0 dispatch/deliver/posted |
|
||||||
|
| P8 | account → agent routing | requires `cfg.bindings` `{agentId,match:{channel:"fabric",accountId}}`; else falls back to default agent |
|
||||||
|
| P9 | dispatch | `runtime.channel.turn` path: `resolveAgentRoute` + `finalizeInboundContext` + `dispatchInboundReplyWithBase` |
|
||||||
|
| P10 | outbound | agent reply posted back to Fabric **as the agent**, exactly **one** message (no chunking; `disableBlockStreaming`) |
|
||||||
|
| P11 | tools | `fabric-register`; `create-{chat,work,report,discussion}-channel` (→ x_type); `discussion-complete` (summary + close) |
|
||||||
|
| P12 | gateway lifecycle | starts inbound on `gateway_start`, stops on `gateway_stop`; no separate sidecar |
|
||||||
|
| P13 | full round-trip | human posts in Fabric → wakeup → agent runs → reply lands in channel as agent |
|
||||||
|
| P14 | file delivery to agent | message attachments downloaded with the agent's guild token to a temp dir; **only local** `MediaPaths`/`MediaTypes` (+ singular) set on the finalized inbound context. No `MediaUrls` — the guild URL is a private host and openclaw's SSRF guard blocks re-fetching it (verified live: `fabric: fetched N attachment(s)`, SSRF WARN gone after the fix) |
|
||||||
|
|
||||||
|
## 8. Fabric.Backend.Guild — files & canvas
|
||||||
|
|
||||||
|
| # | Test point | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| FC1 | `POST /files` (multipart) | returns `{fileId,url:/api/files/:id,name,mimeType,size,expiresAt}`; `expiresAt ≈ now + TTL` (default 7d) |
|
||||||
|
| FC2 | size limit | `> FABRIC_BACKEND_GUILD_FILE_MAX_BYTES` (default 100MB, operator-configurable) → 400 |
|
||||||
|
| FC3 | `GET /files/:id` auth | reachable with Bearer **or** `?access_token=` (browser `<img>/<a>`); no token → 401; image/pdf/av inline, else attachment |
|
||||||
|
| FC4 | bytes round-trip | downloaded content byte-identical to upload |
|
||||||
|
| FC5 | retention sweep | rows past `expiresAt` purged with their blob on boot + hourly (`FilesService.cleanup`) |
|
||||||
|
| FC6 | message attachments | `attachments[]` persisted on the message and returned by `GET …/messages` |
|
||||||
|
| FC7 | canvas single-active | one row per channel (unique `channel_id`); `GET` null when none |
|
||||||
|
| FC8 | canvas share (PUT/POST) | caller becomes `sharerUserId`, `version=1`; re-share replaces (resets version, new sharer); emits `canvas.updated` |
|
||||||
|
| FC9 | canvas update (PATCH) | original sharer only (else 403); `version` increments; emits `canvas.updated` |
|
||||||
|
| FC10 | canvas delete | sharer only (else 403); emits `canvas.removed` |
|
||||||
|
| FC11 | canvas access scope | non-member of a non-public channel → 403 on get/share |
|
||||||
|
|
||||||
|
## 9. Cross-cutting / infra
|
||||||
|
|
||||||
|
| # | Test point | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| X1 | ESM everywhere | all subprojects build & run as ES modules (NodeNext, explicit `.js` imports, CJS deps default-imported) |
|
||||||
|
| X2 | backends boot under ESM | no `ERR_MODULE` / `jwt.sign is not a function` / interop 500s |
|
||||||
|
| X3 | local stack bring-up | 2 mysql + Center + 2 Guilds + Frontend healthy; guild nodes registered; users creatable |
|
||||||
|
| X4 | DB_SYNC schema add | new entities/columns auto-create without data loss (additive) |
|
||||||
|
| X5 | metadata/message separation | one message-id; metadata only at push; frontend/desktop metadata-agnostic; `author=guild` hidden unless debug |
|
||||||
|
| X6 | submodule pointers | parent `Fabric` repo bumped after each submodule change; pushed to `origin/main` |
|
||||||
|
|
||||||
|
## 10. Slash commands (registry / sync / autocomplete)
|
||||||
|
|
||||||
|
| # | Test point | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| SC1 | Guild registry API | `PUT /api/commands {commands}` idempotent full replace; `GET /api/commands` → `{commands,updatedAt}`; both authed (no token → 401). Verified |
|
||||||
|
| SC2 | plugin builds catalog | `buildFabricCommandSpecs(cfg)` via `openclaw/plugin-sdk/native-command-registry` (`listNativeCommandSpecsForConfig`+`findCommandByNativeName`); dynamic arg `choices` resolved to a static snapshot (`resolveCommandArgChoices`). Verified (41 specs, choices incl. dynamic) |
|
||||||
|
| SC3 | plugin syncs on start | `syncFabricCommands` runs after inbound on `gateway_start`; PUTs catalog to each connected guild (one per guild, idempotent). Verified via direct call (`synced 41 -> test-guild1`); in-gateway run needs plugin reinstall + gateway restart |
|
||||||
|
| SC4 | no `nativeCommands` capability | Fabric stays a TEXT-command surface; a `/<cmd>` message is delivered normally → plugin → OpenClaw command system. Only `/no-reply`,`/force-proceed` stay server-intercepted |
|
||||||
|
| SC5 | frontend autocomplete | `/` opens a command panel (filter, ↑↓/Enter/Esc); pick inserts `/<nativeName> `; arg stage shows args + clickable `choices`. Built & deployed; **browser interaction not automated** — catalog fetch + bundle wiring verified |
|
||||||
|
| SC6 | command execution | a registered `/<cmd>` reuses the existing message→plugin→OpenClaw path (text-command + command session + auth); not re-verified end-to-end here (same path as P13/P14) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known coverage gaps / notes
|
||||||
|
|
||||||
|
- **Frontend (§6)** is build-verified (tsc) and was manually checked when
|
||||||
|
built; not browser-exercised in automated runs. ESM migration did not touch
|
||||||
|
the frontend.
|
||||||
|
- **Plugin polish (not yet hardened):** per-agent 15-min token refresh for
|
||||||
|
long-lived sockets (currently re-auth only on gateway restart); messages
|
||||||
|
posted before an agent socket finishes joining are missed (no backfill);
|
||||||
|
guild-token reconnect handling is minimal. Phase 2 (B2 firehose) not built.
|
||||||
|
- **Files & canvas (§8)** backend is automated-e2e verified (upload,
|
||||||
|
`?access_token` download, 401, attachment persistence, canvas
|
||||||
|
share/update/replace/delete + sharer-only/access enforcement; retention
|
||||||
|
deadline asserted, sweep logic unit-level only — not waited out).
|
||||||
|
- **Plugin file delivery (P14) — agent file receipt PROVEN live.**
|
||||||
|
After repointing the fabric binding to a real agent
|
||||||
|
(`bindings[*].agentId` `echo`→`home-developer`, per `~/.openclaw/
|
||||||
|
openclaw.json` — `echo` was never a defined agent), a human posted a
|
||||||
|
file in Fabric → `wakeup` → plugin admitted → **downloaded the
|
||||||
|
attachment with the agent's guild token** to a temp dir → set local
|
||||||
|
`MediaPaths` → the openclaw agent then invoked its `read` tool on
|
||||||
|
exactly that file (`/tmp/fabric-media-echo-<msgId>/0-<name>` in the
|
||||||
|
log). i.e. the uploaded file demonstrably reaches and is opened by the
|
||||||
|
agent. Bug found & fixed during this test: `MediaUrls` (a `localhost`
|
||||||
|
URL) tripped openclaw's SSRF guard — now only local `MediaPaths`/
|
||||||
|
`MediaTypes` are passed.
|
||||||
|
- The agent→Fabric **final-reply leg (P13) still not observed**: the
|
||||||
|
local kimi-backed agent ends its turn after the tool call without
|
||||||
|
emitting final text (no agent reply on any channel all day), so the
|
||||||
|
plugin's `deliver` is never called. Pre-existing local model/agent
|
||||||
|
behavior, independent of the Fabric files/canvas/plugin code.
|
||||||
|
- `discuss`/`work` differ only in x_type label; turn semantics identical —
|
||||||
|
test one, both covered.
|
||||||
|
- Desktop / Android submodules are out of scope (untouched).
|
||||||
@@ -56,9 +56,9 @@
|
|||||||
- [x] 幂等键支持(写接口)
|
- [x] 幂等键支持(写接口)
|
||||||
|
|
||||||
### 2.4 实时通信(MVP 后半)
|
### 2.4 实时通信(MVP 后半)
|
||||||
- [ ] WebSocket 网关接入
|
- [x] WebSocket 网关接入
|
||||||
- [ ] message.created/updated/deleted 事件广播
|
- [x] message.created/updated/deleted 事件广播
|
||||||
- [ ] 在线状态 + typing 事件
|
- [x] 在线状态 + typing 事件
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
### 5.1 自动化测试
|
### 5.1 自动化测试
|
||||||
- [x] 单元测试(auth/service/message/seq)
|
- [x] 单元测试(auth/service/message/seq)
|
||||||
- [x] 集成测试(MySQL + API)
|
- [x] 集成测试(MySQL + API)
|
||||||
- [ ] 合约测试(Center-Guild 协议)
|
- [x] 合约测试(Center-Guild 协议)
|
||||||
|
|
||||||
### 5.2 质量门禁
|
### 5.2 质量门禁
|
||||||
- [x] lint/typecheck/build 全绿
|
- [x] lint/typecheck/build 全绿
|
||||||
@@ -93,11 +93,11 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 6. 部署与运维
|
## 6. 部署与运维
|
||||||
- [ ] `docker-compose.prod.yml`(去掉 `DB_SYNC=true`)
|
- [x] `docker-compose.prod.yml`(去掉 `DB_SYNC=true`)
|
||||||
- [ ] DB migration 机制(TypeORM migration)
|
- [x] DB migration 机制(TypeORM migration)
|
||||||
- [ ] 结构化日志 + request id
|
- [x] 结构化日志 + request id
|
||||||
- [ ] 基础监控指标(QPS、延迟、错误率)
|
- [x] 基础监控指标(QPS、延迟、错误率)
|
||||||
- [ ] 备份与恢复流程文档
|
- [x] 备份与恢复流程文档
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,9 +112,9 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 8. Definition of Done(MVP)
|
## 8. Definition of Done(MVP)
|
||||||
- [ ] 用户可注册登录
|
- [x] 用户可注册登录
|
||||||
- [ ] Guild/Channel/DM 可创建并发消息
|
- [ ] Guild/Channel/DM 可创建并发消息
|
||||||
- [ ] 消息 seq 连续可回补
|
- [x] 消息 seq 连续可回补
|
||||||
- [ ] WebSocket 可实时收发
|
- [x] WebSocket 可实时收发
|
||||||
- [ ] 插件可通过 Bot Token 写入消息并接收 webhook
|
- [x] 插件可通过统一 API Key 写入消息并接收 webhook
|
||||||
- [ ] docker-compose 一键部署可用
|
- [x] docker-compose 一键部署可用
|
||||||
|
|||||||
87
docs/TODO-frontend-desktop.md
Normal file
87
docs/TODO-frontend-desktop.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# TODO - Frontend / Desktop 开发计划
|
||||||
|
|
||||||
|
## 0. 基础约束
|
||||||
|
- [x] 技术栈:Frontend = React + Vite + TS,Desktop = Electron
|
||||||
|
- [x] Frontend/Desktop 子模块初始化
|
||||||
|
- [x] 所有前端接口统一走 Guild/Center API(API Key 模型)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Frontend(Web)
|
||||||
|
|
||||||
|
### 1.1 应用骨架
|
||||||
|
- [x] 路由骨架(登录页 / 工作台 / 聊天页)
|
||||||
|
- [x] 全局布局(侧栏 + 主区 + 状态栏)
|
||||||
|
- [x] API Client 封装(baseURL、API Key、错误处理)
|
||||||
|
- [x] Socket 客户端封装(连接、重连、订阅/退订)
|
||||||
|
|
||||||
|
### 1.2 认证与会话
|
||||||
|
- [x] 登录页(Center 登录)
|
||||||
|
- [x] Access/Refresh token 管理
|
||||||
|
- [x] 过期自动刷新与登出
|
||||||
|
- [x] 基础路由守卫(未登录跳转)
|
||||||
|
|
||||||
|
### 1.3 Guild/Channel 浏览
|
||||||
|
- [x] Guild 列表加载
|
||||||
|
- [x] Channel 列表加载
|
||||||
|
- [x] 频道切换与 URL 状态同步
|
||||||
|
|
||||||
|
### 1.4 消息主链路
|
||||||
|
- [x] 消息拉取(分页/区间)
|
||||||
|
- [x] 发送消息
|
||||||
|
- [x] 编辑消息
|
||||||
|
- [x] 删除消息
|
||||||
|
- [x] 回补提示(next_expected_seq)
|
||||||
|
|
||||||
|
### 1.5 实时能力
|
||||||
|
- [x] 实时消息(created/updated/deleted)
|
||||||
|
- [x] typing/在线状态显示
|
||||||
|
- [x] 断线重连与重拉策略
|
||||||
|
|
||||||
|
### 1.6 体验与稳定性
|
||||||
|
- [x] 基础 loading/empty/error 态
|
||||||
|
- [x] 关键页面可观测日志(requestId)
|
||||||
|
- [x] 前端构建与 lint 门禁
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Desktop(Electron)
|
||||||
|
|
||||||
|
### 2.1 桌面壳
|
||||||
|
- [x] BrowserWindow 配置(尺寸、最小尺寸、标题)
|
||||||
|
- [x] Dev/Prod 加载策略(devServer / 本地包)
|
||||||
|
- [x] 应用菜单与快捷键基础
|
||||||
|
|
||||||
|
### 2.2 安全基线
|
||||||
|
- [x] contextIsolation 保持开启
|
||||||
|
- [x] preload + IPC 白名单(最小暴露)
|
||||||
|
- [x] 禁止任意导航/新窗口策略
|
||||||
|
|
||||||
|
### 2.3 桌面能力
|
||||||
|
- [x] 本地配置存储(API Base/API Key)
|
||||||
|
- [x] 系统通知(新消息)
|
||||||
|
- [x] 托盘与最小化到托盘(可选)
|
||||||
|
|
||||||
|
### 2.4 打包发布
|
||||||
|
- [x] 打包配置(Linux/macOS/Windows)
|
||||||
|
- [x] 版本号与产物命名规范
|
||||||
|
- [x] 一键构建命令与发布说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 联调与验收
|
||||||
|
- [ ] 与 Center/Guild 联调通过(登录、发消息、实时)
|
||||||
|
- [ ] 关键链路冒烟(Web + Desktop)
|
||||||
|
- [x] MVP DoD 文档更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 推荐执行顺序
|
||||||
|
1. Frontend 1.1 应用骨架
|
||||||
|
2. Frontend 1.2 认证与会话
|
||||||
|
3. Frontend 1.3/1.4 核心聊天
|
||||||
|
4. Frontend 1.5 实时完善
|
||||||
|
5. Desktop 2.1/2.2 安全壳
|
||||||
|
6. Desktop 2.3 桌面增强
|
||||||
|
7. Desktop 2.4 打包发布
|
||||||
|
8. 联调验收
|
||||||
83
docs/backup-and-restore-runbook.md
Normal file
83
docs/backup-and-restore-runbook.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Fabric 备份与恢复 Runbook(v1)
|
||||||
|
|
||||||
|
## 1. 范围
|
||||||
|
- MySQL Center:`fabric_center`
|
||||||
|
- MySQL Guild:`fabric_guild`
|
||||||
|
- 可选:持久卷级别备份(`mysql_center_data` / `mysql_guild_data`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 备份策略(建议)
|
||||||
|
- 频率:每天 1 次全量(低峰期)
|
||||||
|
- 保留:最近 7~14 天
|
||||||
|
- 方式:`mysqldump`(逻辑备份)+ 异地对象存储
|
||||||
|
- 校验:每周至少一次恢复演练
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 手动备份命令
|
||||||
|
> 在 `Fabric/` 目录执行
|
||||||
|
|
||||||
|
### 3.1 备份 Center
|
||||||
|
```bash
|
||||||
|
docker exec fabric-mysql-center sh -lc \
|
||||||
|
'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --quick --routines --events fabric_center' \
|
||||||
|
> backup-center-$(date +%F-%H%M%S).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 备份 Guild
|
||||||
|
```bash
|
||||||
|
docker exec fabric-mysql-guild sh -lc \
|
||||||
|
'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --quick --routines --events fabric_guild' \
|
||||||
|
> backup-guild-$(date +%F-%H%M%S).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 压缩
|
||||||
|
```bash
|
||||||
|
gzip backup-center-*.sql
|
||||||
|
gzip backup-guild-*.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 恢复流程
|
||||||
|
|
||||||
|
### 4.1 停写(建议)
|
||||||
|
- 先停 `backend-center` / `backend-guild`,避免恢复时写入冲突。
|
||||||
|
|
||||||
|
### 4.2 恢复 Center
|
||||||
|
```bash
|
||||||
|
gunzip -c backup-center-YYYY-MM-DD-HHMMSS.sql.gz | \
|
||||||
|
docker exec -i fabric-mysql-center sh -lc 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" fabric_center'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 恢复 Guild
|
||||||
|
```bash
|
||||||
|
gunzip -c backup-guild-YYYY-MM-DD-HHMMSS.sql.gz | \
|
||||||
|
docker exec -i fabric-mysql-guild sh -lc 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" fabric_guild'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 恢复后检查
|
||||||
|
- 启动后端服务
|
||||||
|
- 调用:
|
||||||
|
- `GET /api/healthz`
|
||||||
|
- `GET /api/metrics`
|
||||||
|
- 随机抽查:
|
||||||
|
- 用户登录
|
||||||
|
- 节点注册列表
|
||||||
|
- 消息拉取/回补
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 演练清单
|
||||||
|
- [ ] 恢复耗时记录
|
||||||
|
- [ ] 数据一致性抽样
|
||||||
|
- [ ] 回滚预案验证
|
||||||
|
- [ ] 文档更新(命令/版本/风险)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 风险与注意事项
|
||||||
|
- 恢复前先确认目标库环境(避免误写生产)
|
||||||
|
- 密钥与密码不要写入仓库,统一走环境变量
|
||||||
|
- `DB_SYNC` 在生产保持 `false`,结构变更走 migration
|
||||||
47
scripts/register-guild-node.sh
Executable file
47
scripts/register-guild-node.sh
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# scripts/register-guild-node.sh <node_id> <name> <endpoint>
|
||||||
|
# Example:
|
||||||
|
# scripts/register-guild-node.sh guild-node-1 "Guild Node 1" "http://backend-guild:7002"
|
||||||
|
|
||||||
|
if [[ $# -ne 3 ]]; then
|
||||||
|
echo "Usage: $0 <node_id> <name> <endpoint>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_ID="$1"
|
||||||
|
NODE_NAME="$2"
|
||||||
|
NODE_ENDPOINT="$3"
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "docker not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker compose ps backend-center >/dev/null 2>&1; then
|
||||||
|
echo "backend-center service is not available in current docker compose project" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESULT_JSON=$(docker compose exec -T backend-center \
|
||||||
|
npm run -s cli -- node register --node-id "$NODE_ID" --name "$NODE_NAME" --endpoint "$NODE_ENDPOINT")
|
||||||
|
|
||||||
|
OK=$(node -e "const o=JSON.parse(process.argv[1]);process.stdout.write(String(!!o.ok));" "$RESULT_JSON")
|
||||||
|
|
||||||
|
if [[ "$OK" != "true" ]]; then
|
||||||
|
echo "registration failed: $RESULT_JSON" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
API_KEY=$(node -e "const o=JSON.parse(process.argv[1]);process.stdout.write(o.apiKey||'');" "$RESULT_JSON")
|
||||||
|
NODE_ID_OUT=$(node -e "const o=JSON.parse(process.argv[1]);process.stdout.write(o.node?.nodeId||'');" "$RESULT_JSON")
|
||||||
|
|
||||||
|
if [[ -z "$API_KEY" ]]; then
|
||||||
|
echo "registration succeeded but no apiKey returned: $RESULT_JSON" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Node registered: $NODE_ID_OUT"
|
||||||
|
echo "FABRIC_BACKEND_GUILD_CENTER_API_KEY=$API_KEY"
|
||||||
Reference in New Issue
Block a user