Compare commits

..

20 Commits

Author SHA1 Message Date
nav
7a216628d5 chore(plan): complete frontend realtime tasks
Some checks failed
backend-ci / verify (Fabric.Backend.Center) (push) Has been cancelled
backend-ci / verify (Fabric.Backend.Guild) (push) Has been cancelled
2026-05-12 16:04:31 +00:00
nav
7f73607c32 chore(plan): complete frontend message flow including gap hint 2026-05-12 16:00:43 +00:00
nav
f81f9419e0 chore(plan): complete frontend message flow tasks except gap hint 2026-05-12 15:59:18 +00:00
nav
a0be5d6b36 chore(plan): complete frontend guild-channel browser tasks 2026-05-12 15:26:21 +00:00
nav
9d2a330f69 feat(guild): add guild and channel list APIs for frontend browser 2026-05-12 15:25:26 +00:00
nav
271e712804 chore(plan): complete frontend auth and session tasks 2026-05-12 15:09:12 +00:00
nav
1c386e0a80 chore(plan): complete frontend app-shell client wrapper tasks 2026-05-12 13:46:24 +00:00
nav
34442663a3 chore(plan): add frontend-desktop todo and complete frontend routing skeleton step 2026-05-12 13:14:01 +00:00
nav
5a2462a49e chore: bump frontend and desktop submodule refs 2026-05-12 13:10:00 +00:00
nav
86ec39f7d2 test(guild): add concurrent message write verification and fix duplicate messageId index 2026-05-12 12:51:00 +00:00
nav
71ac0f91c6 docs(todo): align MVP DoD with unified api key model and current completion 2026-05-12 12:47:28 +00:00
nav
0f7b99c687 docs(ops): add backup and restore runbook 2026-05-12 12:44:08 +00:00
nav
b7d66f334a feat(observability): add in-process metrics endpoint for qps latency and error-rate 2026-05-12 12:39:20 +00:00
nav
b7c9e34738 feat(observability): add structured request logs with request-id middleware 2026-05-12 12:35:23 +00:00
nav
07d8b20f57 feat(db): add TypeORM migration workflow for center and guild 2026-05-12 12:32:44 +00:00
nav
1b568757cb ops: add production compose with DB_SYNC disabled and env template 2026-05-12 12:30:04 +00:00
nav
7cf0c50921 test(contract): add center-guild registration contract integration test 2026-05-12 12:26:03 +00:00
nav
bccd942898 feat(guild-realtime): add presence and typing websocket events 2026-05-12 12:22:36 +00:00
nav
33d101af22 feat(guild-realtime): broadcast message lifecycle events over websocket 2026-05-12 12:18:01 +00:00
nav
01090273c6 feat(guild-realtime): add websocket gateway with api-key auth and channel rooms 2026-05-12 12:13:19 +00:00
37 changed files with 1320 additions and 26 deletions

23
.env.prod.example Normal file
View 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=

View File

@@ -27,6 +27,7 @@
"@eslint/js": "^10.0.1",
"@nestjs/testing": "^10.4.22",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.10.1",
"@types/supertest": "^7.2.0",
@@ -1036,6 +1037,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1047,6 +1059,16 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -1075,6 +1097,38 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1116,6 +1170,41 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/superagent": {
"version": "8.1.9",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",

View File

@@ -11,7 +11,10 @@
"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/**"
"test:integration": "vitest run src/*.integration.spec.ts --exclude dist/**",
"migration:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate src/migrations/AutoMigration -d src/data-source.ts",
"migration:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d src/data-source.ts",
"migration:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d src/data-source.ts"
},
"dependencies": {
"@nestjs/common": "^10.4.8",
@@ -33,6 +36,7 @@
"@eslint/js": "^10.0.1",
"@nestjs/testing": "^10.4.22",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.10.1",
"@types/supertest": "^7.2.0",

View File

@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { buildTypeOrmConfig } from './database.config';
import { HealthController } from './common/health.controller';
import { MetricsController } from './common/metrics.controller';
import { MetricsService } from './common/metrics.service';
import { AuthModule } from './auth/auth.module';
import { NodesModule } from './nodes/nodes.module';
import { AuditModule } from './audit/audit.module';
@@ -13,6 +15,7 @@ import { AuditModule } from './audit/audit.module';
AuthModule,
NodesModule,
],
controllers: [HealthController],
controllers: [HealthController, MetricsController],
providers: [MetricsService],
})
export class AppModule {}

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { MetricsService } from './metrics.service';
@Controller('metrics')
export class MetricsController {
constructor(private readonly metrics: MetricsService) {}
@Get()
get() {
return this.metrics.snapshot();
}
}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
type Bucket = {
requests: number;
errors: number;
totalDurationMs: number;
};
@Injectable()
export class MetricsService {
private readonly bucket: Bucket = { requests: 0, errors: 0, totalDurationMs: 0 };
private startedAt = Date.now();
record(statusCode: number, durationMs: number): void {
this.bucket.requests += 1;
if (statusCode >= 400) this.bucket.errors += 1;
this.bucket.totalDurationMs += durationMs;
}
snapshot() {
const uptimeSec = Math.max(1, Math.floor((Date.now() - this.startedAt) / 1000));
const qps = this.bucket.requests / uptimeSec;
const avgLatencyMs = this.bucket.requests > 0 ? this.bucket.totalDurationMs / this.bucket.requests : 0;
const errorRate = this.bucket.requests > 0 ? this.bucket.errors / this.bucket.requests : 0;
return {
requests: this.bucket.requests,
errors: this.bucket.errors,
qps,
avgLatencyMs,
errorRate,
uptimeSec,
};
}
}

View File

@@ -0,0 +1,36 @@
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import { MetricsService } from './metrics.service';
type ReqWithId = Request & { requestId?: string };
export function createRequestContextMiddleware(service: 'center' | 'guild', metrics: MetricsService) {
return (req: ReqWithId, res: Response, next: NextFunction): void => {
const headerId = req.headers['x-request-id'];
const requestId =
(Array.isArray(headerId) ? headerId[0] : headerId) || randomUUID();
req.requestId = requestId;
res.setHeader('x-request-id', requestId);
const startedAt = Date.now();
res.on('finish', () => {
const durationMs = Date.now() - startedAt;
metrics.record(res.statusCode, durationMs);
const log = {
level: 'info',
service,
requestId,
method: req.method,
path: req.originalUrl,
statusCode: res.statusCode,
durationMs,
timestamp: new Date().toISOString(),
};
console.log(JSON.stringify(log));
});
next();
};
}

View File

@@ -0,0 +1,16 @@
import 'reflect-metadata';
import { DataSource, DataSourceOptions } from 'typeorm';
import { buildTypeOrmConfig } from './database.config';
const cfg = buildTypeOrmConfig();
const options: DataSourceOptions = {
...(cfg as Record<string, unknown>),
type: 'mysql',
migrations: ['src/migrations/*.{ts,js}'],
synchronize: false,
} as DataSourceOptions;
export const AppDataSource = new DataSource(options);
export default AppDataSource;

View File

@@ -2,13 +2,14 @@ import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createHmac, randomUUID } from 'crypto';
process.env.DB_HOST = '127.0.0.1';
process.env.DB_PORT = '3307';
process.env.DB_USER = 'fabric';
process.env.DB_PASSWORD = 'fabric';
process.env.DB_NAME = 'fabric_center';
process.env.DB_SYNC = 'false';
process.env.DB_SYNC = 'true';
process.env.CENTER_SHARED_SECRET = 'test-center-secret';
process.env.JWT_ACCESS_SECRET = 'test-access-secret';
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
@@ -37,4 +38,32 @@ describe('center integration (mysql + api)', () => {
expect(res.body.ok).toBe(true);
expect(res.body.database).toBe('ready');
});
it('POST /api/nodes/register follows center-guild contract (version + hmac)', async () => {
const body = {
nodeId: `guild-node-${Date.now()}`,
name: 'Guild Node Contract Test',
endpoint: `http://guild-${Date.now()}:7002`,
};
const timestamp = new Date().toISOString();
const nonce = randomUUID();
const canonical = ['POST', '/api/nodes/register', timestamp, nonce, JSON.stringify(body)].join('\n');
const signature = createHmac('sha256', process.env.CENTER_SHARED_SECRET as string)
.update(canonical)
.digest('hex');
const res = await request(app.getHttpServer())
.post('/api/nodes/register')
.set('x-fabric-version', '1')
.set('x-fabric-timestamp', timestamp)
.set('x-fabric-nonce', nonce)
.set('x-fabric-signature', signature)
.send(body);
expect(res.status).toBe(201);
expect(res.body.status).toBe('accepted');
expect(res.body.negotiatedVersion).toBe('1');
expect(res.body.node.nodeId).toBe(body.nodeId);
});
});

View File

@@ -3,6 +3,8 @@ import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { createRequestContextMiddleware } from './common/request-context.middleware';
import { MetricsService } from './common/metrics.service';
function requireEnv(name: string): string {
const value = process.env[name];
@@ -28,6 +30,8 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
const metrics = app.get(MetricsService);
app.use(createRequestContextMiddleware('center', metrics));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,

View File

@@ -11,19 +11,23 @@
"@nestjs/common": "^10.4.8",
"@nestjs/core": "^10.4.8",
"@nestjs/platform-express": "^10.4.8",
"@nestjs/platform-socket.io": "^10.4.22",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^10.4.22",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"mysql2": "^3.22.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.29"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@nestjs/testing": "^10.4.22",
"@types/express": "^5.0.6",
"@types/node": "^22.10.1",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^8.59.3",
@@ -507,6 +511,66 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/platform-socket.io": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz",
"integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==",
"license": "MIT",
"dependencies": {
"socket.io": "4.8.1",
"tslib": "2.8.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/websockets": "^10.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/platform-socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@nestjs/platform-socket.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@nestjs/platform-socket.io/node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/@nestjs/swagger": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz",
@@ -601,6 +665,29 @@
"typeorm": "^0.3.0 || ^1.0.0-dev"
}
},
"node_modules/@nestjs/websockets": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz",
"integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==",
"license": "MIT",
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-socket.io": "^10.0.0",
"reflect-metadata": "^0.1.12 || ^0.2.0",
"rxjs": "^7.1.0"
},
"peerDependenciesMeta": {
"@nestjs/platform-socket.io": {
"optional": true
}
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -926,6 +1013,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@sqltools/formatter": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
@@ -1025,6 +1118,17 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1036,6 +1140,16 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -1043,6 +1157,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1064,6 +1187,38 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1087,6 +1242,41 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/superagent": {
"version": "8.1.9",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
@@ -1117,6 +1307,15 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
@@ -1812,6 +2011,15 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -2378,6 +2586,59 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.7",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz",
"integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -4045,6 +4306,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4660,6 +4930,116 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5753,6 +6133,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -11,25 +11,32 @@
"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/**"
"test:integration": "vitest run src/*.integration.spec.ts --exclude dist/**",
"migration:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate src/migrations/AutoMigration -d src/data-source.ts",
"migration:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d src/data-source.ts",
"migration:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d src/data-source.ts"
},
"dependencies": {
"@nestjs/common": "^10.4.8",
"@nestjs/core": "^10.4.8",
"@nestjs/platform-express": "^10.4.8",
"@nestjs/platform-socket.io": "^10.4.22",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^10.4.22",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"mysql2": "^3.22.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.29"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@nestjs/testing": "^10.4.22",
"@types/express": "^5.0.6",
"@types/node": "^22.10.1",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^8.59.3",

View File

@@ -3,22 +3,27 @@ import { APP_GUARD } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { buildTypeOrmConfig } from './database.config';
import { HealthController } from './common/health.controller';
import { MetricsController } from './common/metrics.controller';
import { MetricsService } from './common/metrics.service';
import { ApiKeyGuard } from './common/api-key.guard';
import { GuildsModule } from './guilds/guilds.module';
import { ChannelsModule } from './channels/channels.module';
import { MessagingModule } from './messaging/messaging.module';
import { EventsModule } from './events/events.module';
import { RealtimeModule } from './realtime/realtime.module';
@Module({
imports: [
TypeOrmModule.forRoot(buildTypeOrmConfig()),
EventsModule,
RealtimeModule,
GuildsModule,
ChannelsModule,
MessagingModule,
],
controllers: [HealthController],
controllers: [HealthController, MetricsController],
providers: [
MetricsService,
{
provide: APP_GUARD,
useClass: ApiKeyGuard,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { MetricsService } from './metrics.service';
@Controller('metrics')
export class MetricsController {
constructor(private readonly metrics: MetricsService) {}
@Get()
get() {
return this.metrics.snapshot();
}
}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
type Bucket = {
requests: number;
errors: number;
totalDurationMs: number;
};
@Injectable()
export class MetricsService {
private readonly bucket: Bucket = { requests: 0, errors: 0, totalDurationMs: 0 };
private startedAt = Date.now();
record(statusCode: number, durationMs: number): void {
this.bucket.requests += 1;
if (statusCode >= 400) this.bucket.errors += 1;
this.bucket.totalDurationMs += durationMs;
}
snapshot() {
const uptimeSec = Math.max(1, Math.floor((Date.now() - this.startedAt) / 1000));
const qps = this.bucket.requests / uptimeSec;
const avgLatencyMs = this.bucket.requests > 0 ? this.bucket.totalDurationMs / this.bucket.requests : 0;
const errorRate = this.bucket.requests > 0 ? this.bucket.errors / this.bucket.requests : 0;
return {
requests: this.bucket.requests,
errors: this.bucket.errors,
qps,
avgLatencyMs,
errorRate,
uptimeSec,
};
}
}

View File

@@ -0,0 +1,36 @@
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import { MetricsService } from './metrics.service';
type ReqWithId = Request & { requestId?: string };
export function createRequestContextMiddleware(service: 'center' | 'guild', metrics: MetricsService) {
return (req: ReqWithId, res: Response, next: NextFunction): void => {
const headerId = req.headers['x-request-id'];
const requestId =
(Array.isArray(headerId) ? headerId[0] : headerId) || randomUUID();
req.requestId = requestId;
res.setHeader('x-request-id', requestId);
const startedAt = Date.now();
res.on('finish', () => {
const durationMs = Date.now() - startedAt;
metrics.record(res.statusCode, durationMs);
const log = {
level: 'info',
service,
requestId,
method: req.method,
path: req.originalUrl,
statusCode: res.statusCode,
durationMs,
timestamp: new Date().toISOString(),
};
console.log(JSON.stringify(log));
});
next();
};
}

View File

@@ -0,0 +1,16 @@
import 'reflect-metadata';
import { DataSource, DataSourceOptions } from 'typeorm';
import { buildTypeOrmConfig } from './database.config';
const cfg = buildTypeOrmConfig();
const options: DataSourceOptions = {
...(cfg as Record<string, unknown>),
type: 'mysql',
migrations: ['src/migrations/*.{ts,js}'],
synchronize: false,
} as DataSourceOptions;
export const AppDataSource = new DataSource(options);
export default AppDataSource;

View File

@@ -9,7 +9,6 @@ export class Message {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index()
@Column({ type: 'varchar', length: 80, unique: true })
messageId!: string;

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DataSource } from 'typeorm';
import { Channel } from './entities/channel.entity';
process.env.DB_HOST = '127.0.0.1';
process.env.DB_PORT = '3308';
@@ -13,6 +15,7 @@ process.env.FABRIC_API_KEY = 'test-api-key';
describe('guild integration (mysql + api)', () => {
let app: INestApplication;
let dataSource: DataSource;
beforeAll(async () => {
const { AppModule } = await import('./app.module');
@@ -23,6 +26,9 @@ describe('guild integration (mysql + api)', () => {
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api');
await app.init();
dataSource = app.get(DataSource);
await dataSource.dropDatabase();
await dataSource.synchronize();
}, 30000);
afterAll(async () => {
@@ -40,4 +46,32 @@ describe('guild integration (mysql + api)', () => {
const res = await request(app.getHttpServer()).get('/api/channels/non-exist/messages');
expect(res.status).toBe(401);
});
it('supports concurrent message writes with continuous seq', async () => {
const channelRepo = dataSource.getRepository(Channel);
const channel = await channelRepo.save(
channelRepo.create({
guildId: 'test-guild',
name: `concurrency-${Date.now()}`,
kind: 'text',
isPrivate: false,
lastSeq: 0,
}),
);
const concurrent = 10;
const tasks = Array.from({ length: concurrent }, (_, i) =>
request(app.getHttpServer())
.post(`/api/channels/${channel.id}/messages`)
.set('x-api-key', 'test-api-key')
.send({ content: `hello-${i + 1}`, authorUserId: 'u1' }),
);
const responses = await Promise.all(tasks);
const seqs = responses.map((r) => r.body.seq).sort((a, b) => a - b);
expect(responses.every((r) => r.status === 201)).toBe(true);
expect(new Set(seqs).size).toBe(concurrent);
expect(seqs).toEqual(Array.from({ length: concurrent }, (_, i) => i + 1));
});
});

View File

@@ -3,10 +3,14 @@ import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { createRequestContextMiddleware } from './common/request-context.middleware';
import { MetricsService } from './common/metrics.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
const metrics = app.get(MetricsService);
app.use(createRequestContextMiddleware('guild', metrics));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,

View File

@@ -18,6 +18,7 @@ import { Message } from '../entities/message.entity';
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
import { EventsService } from '../events/events.service';
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
import { RealtimeGateway } from '../realtime/realtime.gateway';
const EDIT_WINDOW_MS = 15 * 60 * 1000;
const DEFAULT_PAGE_LIMIT = 50;
@@ -34,6 +35,7 @@ export class MessagingController {
@InjectRepository(IdempotencyRecord)
private readonly idemRepo: Repository<IdempotencyRecord>,
private readonly events: EventsService,
private readonly realtime: RealtimeGateway,
) {}
private async getIdempotentResponse(
@@ -125,6 +127,7 @@ export class MessagingController {
actorId: body.authorUserId ?? 'anonymous',
data: responseBody,
});
this.realtime.emitChannelEvent(channelId, 'message.created', responseBody);
return responseBody;
}
@@ -161,6 +164,7 @@ export class MessagingController {
actorId: saved.authorUserId,
data: responseBody,
});
this.realtime.emitChannelEvent(channelId, 'message.updated', responseBody);
return responseBody;
}
@@ -202,6 +206,11 @@ export class MessagingController {
deletedAt: item.deletedAt?.toISOString() ?? null,
},
});
this.realtime.emitChannelEvent(channelId, 'message.deleted', {
messageId,
seq: item.seq,
deletedAt: item.deletedAt?.toISOString() ?? null,
});
return responseBody;
}

View File

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

View File

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

90
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,90 @@
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:
PORT: 7001
DB_HOST: mysql-center
DB_PORT: 3306
DB_USER: ${MYSQL_CENTER_USER:-fabric}
DB_PASSWORD: ${MYSQL_CENTER_PASSWORD}
DB_NAME: ${MYSQL_CENTER_DATABASE:-fabric_center}
DB_SYNC: "false"
DB_LOGGING: "false"
CENTER_SHARED_SECRET: ${CENTER_SHARED_SECRET}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET}
JWT_REFRESH_SECRET: ${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:
PORT: 7002
DB_HOST: mysql-guild
DB_PORT: 3306
DB_USER: ${MYSQL_GUILD_USER:-fabric}
DB_PASSWORD: ${MYSQL_GUILD_PASSWORD}
DB_NAME: ${MYSQL_GUILD_DATABASE:-fabric_guild}
DB_SYNC: "false"
DB_LOGGING: "false"
FABRIC_API_KEY: ${FABRIC_API_KEY}
FABRIC_WEBHOOK_URL: ${FABRIC_WEBHOOK_URL:-}
FABRIC_WEBHOOK_SECRET: ${FABRIC_WEBHOOK_SECRET:-}
ports:
- "7002:7002"
volumes:
mysql_center_data:
mysql_guild_data:

View File

@@ -56,9 +56,9 @@
- [x] 幂等键支持(写接口)
### 2.4 实时通信MVP 后半)
- [ ] WebSocket 网关接入
- [ ] message.created/updated/deleted 事件广播
- [ ] 在线状态 + typing 事件
- [x] WebSocket 网关接入
- [x] message.created/updated/deleted 事件广播
- [x] 在线状态 + typing 事件
---
@@ -83,7 +83,7 @@
### 5.1 自动化测试
- [x] 单元测试auth/service/message/seq
- [x] 集成测试MySQL + API
- [ ] 合约测试Center-Guild 协议)
- [x] 合约测试Center-Guild 协议)
### 5.2 质量门禁
- [x] lint/typecheck/build 全绿
@@ -93,11 +93,11 @@
---
## 6. 部署与运维
- [ ] `docker-compose.prod.yml`(去掉 `DB_SYNC=true`
- [ ] DB migration 机制TypeORM migration
- [ ] 结构化日志 + request id
- [ ] 基础监控指标QPS、延迟、错误率
- [ ] 备份与恢复流程文档
- [x] `docker-compose.prod.yml`(去掉 `DB_SYNC=true`
- [x] DB migration 机制TypeORM migration
- [x] 结构化日志 + request id
- [x] 基础监控指标QPS、延迟、错误率
- [x] 备份与恢复流程文档
---
@@ -112,9 +112,9 @@
---
## 8. Definition of DoneMVP
- [ ] 用户可注册登录
- [x] 用户可注册登录
- [ ] Guild/Channel/DM 可创建并发消息
- [ ] 消息 seq 连续可回补
- [ ] WebSocket 可实时收发
- [ ] 插件可通过 Bot Token 写入消息并接收 webhook
- [ ] docker-compose 一键部署可用
- [x] 消息 seq 连续可回补
- [x] WebSocket 可实时收发
- [x] 插件可通过统一 API Key 写入消息并接收 webhook
- [x] docker-compose 一键部署可用

View File

@@ -0,0 +1,87 @@
# TODO - Frontend / Desktop 开发计划
## 0. 基础约束
- [x] 技术栈Frontend = React + Vite + TSDesktop = Electron
- [x] Frontend/Desktop 子模块初始化
- [ ] 所有前端接口统一走 Guild/Center APIAPI Key 模型)
---
## 1. FrontendWeb
### 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 体验与稳定性
- [ ] 基础 loading/empty/error 态
- [ ] 关键页面可观测日志requestId
- [ ] 前端构建与 lint 门禁
---
## 2. DesktopElectron
### 2.1 桌面壳
- [ ] BrowserWindow 配置(尺寸、最小尺寸、标题)
- [ ] Dev/Prod 加载策略devServer / 本地包)
- [ ] 应用菜单与快捷键基础
### 2.2 安全基线
- [ ] contextIsolation 保持开启
- [ ] preload + IPC 白名单(最小暴露)
- [ ] 禁止任意导航/新窗口策略
### 2.3 桌面能力
- [ ] 本地配置存储API Base/API Key
- [ ] 系统通知(新消息)
- [ ] 托盘与最小化到托盘(可选)
### 2.4 打包发布
- [ ] 打包配置Linux/macOS/Windows
- [ ] 版本号与产物命名规范
- [ ] 一键构建命令与发布说明
---
## 3. 联调与验收
- [ ] 与 Center/Guild 联调通过(登录、发消息、实时)
- [ ] 关键链路冒烟Web + Desktop
- [ ] 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. 联调验收

View File

@@ -0,0 +1,83 @@
# Fabric 备份与恢复 Runbookv1
## 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