feat: bootstrap from Fabric monorepo

This commit is contained in:
nav
2026-05-13 07:06:02 +00:00
commit 03a3342d2a
40 changed files with 7210 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Controller('healthz')
export class HealthController {
constructor(private readonly dataSource: DataSource) {}
@Get()
async get() {
try {
await this.dataSource.query('SELECT 1');
return {
ok: true,
service: 'center',
database: 'ready',
};
} catch {
throw new ServiceUnavailableException({
ok: false,
service: 'center',
database: 'not_ready',
});
}
}
}

38
src/common/hmac.ts Normal file
View File

@@ -0,0 +1,38 @@
import { createHmac, timingSafeEqual } from 'crypto';
export type HmacInput = {
method: string;
path: string;
timestamp: string;
nonce: string;
body: string;
};
const CLOCK_SKEW_MS = 5 * 60 * 1000;
export function buildCanonical(input: HmacInput): string {
return [
input.method.toUpperCase(),
input.path,
input.timestamp,
input.nonce,
input.body,
].join('\n');
}
export function signCanonical(secret: string, canonical: string): string {
return createHmac('sha256', secret).update(canonical).digest('hex');
}
export function verifyRequestTime(timestamp: string): boolean {
const ts = Date.parse(timestamp);
if (Number.isNaN(ts)) return false;
return Math.abs(Date.now() - ts) <= CLOCK_SKEW_MS;
}
export function safeEqualHex(a: string, b: string): boolean {
const aa = Buffer.from(a, 'hex');
const bb = Buffer.from(b, 'hex');
if (aa.length !== bb.length) return false;
return timingSafeEqual(aa, bb);
}

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();
};
}

7
src/common/version.ts Normal file
View File

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