Compare commits

17 Commits

Author SHA1 Message Date
1c1f280553 fix(cli): set-admin transaction uses isAdmin=true filter (TypeORM rejects empty-where)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:25:06 +01:00
f1ca33f2a2 feat(auth): center-scoped single admin + GET /admin-email + cli
Triage channels in Guilds need to deliver every message to a single
"observer" admin user across the whole Center deployment (regardless of
on-duty / mention). This adds the data + auth surface for that.

## Schema

`users.isAdmin TINYINT DEFAULT 0` (synchronize:true auto-applies; cli
`user set-admin` enforces at-most-one in a transaction).

## CLI (subject `user`)

- `set-admin --email <e>` — clears every other admin row first, then
  marks the target. Returns `{admin: {email, userId}}`
- `clear-admin` — unsets all (returns `{cleared: N}`)
- `show-admin` — prints `{admin: {email, userId}}` or `{admin: null}`

## HTTP

`GET /auth/admin-email` (NOT @Public — requires a guild-node api key
via the existing CenterApiKeyGuard). Returns:
  - `{email: "...", userId: "..."}` if an admin exists
  - `null` (literal JSON) if no admin

Guild backends cache the result (1 day TTL per spec; with cli refresh
override on guild side).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:09:58 +01:00
9f7216565b fix(center): graceful 4xx when OIDC issuer/token endpoint unreachable
Wrap discovery + token-exchange fetch in try/catch so a DNS/network
failure returns BadRequest/Unauthorized instead of a bare 500.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:47:28 +01:00
2a394969d2 feat(center): OIDC login (auto-provision by email) + CLI config-oidc
- OidcConfig entity (single row) + DB registration.
- OidcService: discovery, Authorization Code + PKCE, state/ticket as
  short-lived signed JWTs (no server session), userinfo/id_token claim
  resolution. Auto-provisions a passwordless user by email.
- Public endpoints /auth/oidc/{status,start,callback,exchange}; callback
  bounces a one-time ticket to the SPA in the URL fragment.
- AuthService.provisionOidcUser + issueSessionForUserId (login shape).
- CLI: 'config oidc' upserts issuer/clientId/secret/callback/redirect/
  scopes/enabled (secret masked on print).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:44:49 +01:00
6afb935302 fix(security): close Critical auth gaps (C1/C2/C3)
C1: replace fragile path.endsWith() guard whitelist with a metadata
    @Public() decorator + Reflector (no more path-shape bypass surface).
C2: CenterApiKeyGuard attaches the authenticated GuildNode; introspect
    & resolve-names now reject when body.guildNodeId != that node
    (stops one node probing/enumerating another guild's identities).
C3: heartbeat/status are self-only (a node can't revoke/hijack another);
    GET /nodes no longer returns apiKeyHash (credential-hash leak).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:47:01 +01:00
aa9d59a952 docs: rewrite README to match current architecture
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:53:22 +01:00
44aa34d1ff refactor: migrate to ES modules
package.json type=module, tsconfig module/moduleResolution=NodeNext,
target es2022, explicit .js on all relative imports. Center: jsonwebtoken
& bcryptjs switched to default imports (ESM/CJS interop). Verified:
builds, boots, full auth + plugin round-trip work under ESM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:47:35 +01:00
dea946653b feat(center): API-key agent auth
UserApiKey (apiKeyHash->userId); CLI 'user apikey --email'; POST
/auth/agent/login {apiKey} -> normal user session (api-key-guard exempt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:52:42 +01:00
3da51a60bc feat(center): POST /auth/resolve-names
Resolve display names/emails to userIds within a guild node's active
members (api-key auth). Used by guild for <@user.name:NAME> translation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:47:01 +01:00
bc0d1ba8bf feat(center): user display name + GET/PATCH /auth/me
- User.name column, defaults to email on register
- GET /auth/me, PATCH /auth/me to view/change own name (api-key exempt)
- login + guild members responses now include name

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:09:40 +01:00
nav
2792f78ada fix(center): exempt guild join/members auth endpoints from api-key guard 2026-05-15 00:32:33 +00:00
nav
1eb30348a2 feat(center): guild join and guild members APIs; stop auto-joining all guilds 2026-05-14 16:57:57 +00:00
nav
ebc3571823 fix(center): enable CORS for auth preflight and desktop origins 2026-05-14 16:26:55 +00:00
nav
0b32dc8e3c feat(cli): move user and guild registration from API to local CLI 2026-05-14 14:43:59 +00:00
nav
7afd220b4a feat(auth): split api-key boundary for frontend auth flow 2026-05-14 14:17:07 +00:00
nav
81dfc227e3 refactor(center): prefix environment variables with FABRIC_BACKEND_CENTER 2026-05-13 12:58:28 +00:00
nav
0a4cb62065 refactor(center): local-only guild register endpoint without shared secret 2026-05-13 08:41:45 +00:00
30 changed files with 1157 additions and 241 deletions

View File

@@ -1,20 +1,17 @@
# Server # Server
PORT=7001 FABRIC_BACKEND_CENTER_PORT=7001
# MySQL # MySQL
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
# Auth (to be used in auth module) # Auth (to be used in auth module)
JWT_ACCESS_SECRET=change-me-access FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET=change-me-access
JWT_REFRESH_SECRET=change-me-refresh FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET=change-me-refresh
JWT_ACCESS_EXPIRES_IN=15m FABRIC_BACKEND_CENTER_JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=30d FABRIC_BACKEND_CENTER_JWT_REFRESH_EXPIRES_IN=30d
# Center <-> Guild handshake
CENTER_SHARED_SECRET=change-me-center-secret

View File

@@ -1,14 +1,59 @@
# Fabric.Backend.Center # Fabric.Backend.Center
Identity Hub service for Fabric. The **identity hub** for Fabric (NestJS, ES modules, MySQL/TypeORM). Default
port `7001`, global prefix `/api`.
## Scope (MVP) Center is the single identity authority. Guild nodes register with it and
- User register/login introspect the tokens it issues; the frontend uses it to log in and to
- Session/token management discover which guilds a user belongs to.
- Guild Node registration + shared-secret handshake
- Center-level audit logs
## Next ## Responsibilities
- API skeleton (NestJS)
- Auth module - **Users & sessions** — register/login, JWT access + refresh tokens, `GET`/
- Guild node registry module `PATCH /auth/me`. User display name defaults to the email until changed.
- **Agent auth** — per-agent **API keys** (`fak_…`); `POST /auth/agent/login`
exchanges a key for a normal user session (used by `Fabric.OpenclawPlugin`).
- **Guild-node registry** — nodes register (`/api/nodes/register`, localhost
or node API key) and are handed out to users as endpoints + short-lived
guild access tokens.
- **Name resolution** — `POST /auth/resolve-names` maps `name`/email →
userId, scoped to a guild's members (used for `<@user.name:NAME>` mentions).
- **Membership** — `POST /auth/me/guilds/join`, `GET /auth/me/guilds`
(returns guilds + fresh guild access tokens), `GET /auth/guilds/:nodeId/members`.
## CLI
```bash
node dist/cli.js user create --email <e> --password <p>
node dist/cli.js user apikey --email <e> [--label <l>] # prints fak_… once
node dist/cli.js node register --node-id <id> --name <n> --endpoint <url>
```
## Run
```bash
npm install
npm run build && npm start # or: npm run start:dev
```
Typically run via the root `docker-compose.local.yml` (service
`backend-center`). MySQL schema is auto-managed (`DB_SYNC`).
## Notable env
- `FABRIC_BACKEND_CENTER_PORT` (default 7001)
- `FABRIC_BACKEND_CENTER_DB_*` (host/port/user/password/name)
- JWT signing secret(s) — see `src/` config
## Auth model
A global `CenterApiKeyGuard` protects most routes; auth/session endpoints
(`login`, `agent/login`, `refresh`, `logout`, `me`, `me/guilds*`,
`resolve-names`, guild members, node register) are exempted so users, agents,
and nodes can bootstrap.
## Notes
- ES modules (`NodeNext`); CJS deps are default-imported
(`import jwt from 'jsonwebtoken'`, `import bcrypt from 'bcryptjs'`).
- `@IsEmail()` rejects single-character TLDs — use e.g. `@t.tt`.

View File

@@ -2,9 +2,11 @@
"name": "fabric-backend-center", "name": "fabric-backend-center",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"description": "Fabric Identity Hub (Center service)", "description": "Fabric Identity Hub (Center service)",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"cli": "node dist/cli.js",
"start": "node dist/main.js", "start": "node dist/main.js",
"start:dev": "ts-node src/main.ts", "start:dev": "ts-node src/main.ts",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",

View File

@@ -1,15 +1,15 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { buildTypeOrmConfig } from './database.config'; import { buildTypeOrmConfig } from './database.config.js';
import { HealthController } from './common/health.controller'; import { HealthController } from './common/health.controller.js';
import { MetricsController } from './common/metrics.controller'; import { MetricsController } from './common/metrics.controller.js';
import { MetricsService } from './common/metrics.service'; import { MetricsService } from './common/metrics.service.js';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module.js';
import { NodesModule } from './nodes/nodes.module'; import { NodesModule } from './nodes/nodes.module.js';
import { AuditModule } from './audit/audit.module'; import { AuditModule } from './audit/audit.module.js';
import { CenterApiKeyGuard } from './common/center-api-key.guard'; import { CenterApiKeyGuard } from './common/center-api-key.guard.js';
import { GuildNode } from './entities/guild-node.entity'; import { GuildNode } from './entities/guild-node.entity.js';
@Module({ @Module({
imports: [ imports: [

View File

@@ -1,7 +1,7 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLog } from '../entities/audit-log.entity'; import { AuditLog } from '../entities/audit-log.entity.js';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service.js';
@Global() @Global()
@Module({ @Module({

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AuditLog } from '../entities/audit-log.entity'; import { AuditLog } from '../entities/audit-log.entity.js';
export type AuditWriteInput = { export type AuditWriteInput = {
action: string; action: string;

View File

@@ -1,43 +1,122 @@
import { Body, Controller, Get, Headers, Post, UnauthorizedException } from '@nestjs/common'; import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post, Req, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service.js';
import { RegisterDto } from './dto.register.dto'; import { LoginDto } from './dto.login.dto.js';
import { LoginDto } from './dto.login.dto'; import { RefreshDto } from './dto.refresh.dto.js';
import { RefreshDto } from './dto.refresh.dto'; import { LogoutDto } from './dto.logout.dto.js';
import { LogoutDto } from './dto.logout.dto'; import { UpdateMeDto } from './dto.update-me.dto.js';
import { Public } from '../common/public.decorator.js';
import type { AuthedGuildNode } from '../common/center-api-key.guard.js';
function bearer(authorization?: string): string {
return authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
}
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Post('register') @Public()
register(@Body() body: RegisterDto) {
return this.authService.register(body);
}
@Post('login') @Post('login')
login(@Body() body: LoginDto) { login(@Body() body: LoginDto) {
return this.authService.login(body); return this.authService.login(body);
} }
@Public()
@Post('refresh') @Post('refresh')
refresh(@Body() body: RefreshDto) { refresh(@Body() body: RefreshDto) {
return this.authService.refresh(body.refreshToken); return this.authService.refresh(body.refreshToken);
} }
@Public()
@Post('logout') @Post('logout')
logout(@Body() body: LogoutDto) { logout(@Body() body: LogoutDto) {
return this.authService.logout(body.refreshToken); return this.authService.logout(body.refreshToken);
} }
@Public()
@Get('me')
me(@Headers('authorization') authorization?: string) {
const token = bearer(authorization);
if (!token) throw new UnauthorizedException('missing bearer token');
return this.authService.getMe(token);
}
@Public()
@Patch('me')
updateMe(@Headers('authorization') authorization: string | undefined, @Body() body: UpdateMeDto) {
const token = bearer(authorization);
if (!token) throw new UnauthorizedException('missing bearer token');
return this.authService.updateMe(token, body.name);
}
@Public()
@Get('me/guilds') @Get('me/guilds')
meGuilds(@Headers('authorization') authorization?: string) { meGuilds(@Headers('authorization') authorization?: string) {
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : ''; const token = bearer(authorization);
if (!token) throw new UnauthorizedException('missing bearer token'); if (!token) throw new UnauthorizedException('missing bearer token');
return this.authService.listMyGuilds(token); return this.authService.listMyGuilds(token);
} }
@Public()
@Post('me/guilds/join')
joinGuild(@Headers('authorization') authorization: string | undefined, @Body() body: { guildNodeId?: string }) {
const token = bearer(authorization);
if (!token) throw new UnauthorizedException('missing bearer token');
return this.authService.joinGuild(token, String(body?.guildNodeId ?? ''));
}
@Public()
@Get('guilds/:guildNodeId/members')
guildMembers(@Headers('authorization') authorization: string | undefined, @Param('guildNodeId') guildNodeId: string) {
const token = bearer(authorization);
if (!token) throw new UnauthorizedException('missing bearer token');
return this.authService.listGuildMembers(token, guildNodeId);
}
@Public()
@Post('agent/login')
agentLogin(@Body() body: { apiKey?: string }) {
return this.authService.agentLogin(String(body?.apiKey ?? ''));
}
// Not @Public(): requires a guild node api key. Returns the single
// Center-scoped admin user `{email, userId}` so the calling guild can
// mark them as triage observers, or `null` if no admin is set.
@Get('admin-email')
getAdminEmail(@Req() req: { guildNode?: AuthedGuildNode }) {
if (!req.guildNode) throw new UnauthorizedException('guild node api key required');
return this.authService.getAdminEmail();
}
// Not @Public(): requires a guild node api key. The guard attaches the
// authenticated node; a node may only introspect tokens for its own
// guild (Center C2 — prevents cross-tenant identity probing).
@Post('introspect') @Post('introspect')
introspect(@Body() body: { token?: string; guildNodeId?: string }) { introspect(
return this.authService.introspectGuildToken(body?.token ?? '', body?.guildNodeId ?? ''); @Req() req: { guildNode?: AuthedGuildNode },
@Body() body: { token?: string; guildNodeId?: string },
) {
const requested = String(body?.guildNodeId ?? '');
if (!req.guildNode || req.guildNode.nodeId !== requested) {
throw new ForbiddenException('api key not authorized for this guild node');
}
return this.authService.introspectGuildToken(body?.token ?? '', requested);
}
// Not @Public(): same node-ownership rule as introspect (Center C2 —
// prevents one node enumerating another guild's member identities).
@Post('resolve-names')
resolveNames(
@Req() req: { guildNode?: AuthedGuildNode },
@Body() body: { guildNodeId?: string; names?: string[] },
) {
const requested = String(body?.guildNodeId ?? '');
if (!req.guildNode || req.guildNode.nodeId !== requested) {
throw new ForbiddenException('api key not authorized for this guild node');
}
return this.authService.resolveNames(
requested,
Array.isArray(body?.names) ? body.names : [],
);
} }
} }

View File

@@ -1,14 +1,18 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service.js';
import { User } from '../entities/user.entity'; import { OidcController } from './oidc.controller.js';
import { GuildNode } from '../entities/guild-node.entity'; import { OidcService } from './oidc.service.js';
import { GuildUser } from '../entities/guild-user.entity'; import { User } from '../entities/user.entity.js';
import { GuildNode } from '../entities/guild-node.entity.js';
import { GuildUser } from '../entities/guild-user.entity.js';
import { UserApiKey } from '../entities/user-api-key.entity.js';
import { OidcConfig } from '../entities/oidc-config.entity.js';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser])], imports: [TypeOrmModule.forFeature([User, GuildNode, GuildUser, UserApiKey, OidcConfig])],
controllers: [AuthController], controllers: [AuthController, OidcController],
providers: [AuthService], providers: [AuthService, OidcService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -5,31 +5,37 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { User } from '../entities/user.entity'; import { randomBytes } from 'crypto';
import { GuildNode } from '../entities/guild-node.entity'; import { User } from '../entities/user.entity.js';
import { GuildUser } from '../entities/guild-user.entity'; import { GuildNode } from '../entities/guild-node.entity.js';
import { RegisterDto } from './dto.register.dto'; import { GuildUser } from '../entities/guild-user.entity.js';
import { LoginDto } from './dto.login.dto'; import { UserApiKey } from '../entities/user-api-key.entity.js';
import { AuditService } from '../audit/audit.service'; import { RegisterDto } from './dto.register.dto.js';
import { parseDurationToSeconds } from './token.util'; import { LoginDto } from './dto.login.dto.js';
import { AuditService } from '../audit/audit.service.js';
import { parseDurationToSeconds } from './token.util.js';
function signAccessToken(userId: string, email: string): string { function signAccessToken(userId: string, email: string): string {
const secret = process.env.JWT_ACCESS_SECRET as string; const secret = process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string;
const expiresIn = parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m', 900); const expiresIn = parseDurationToSeconds(process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_EXPIRES_IN ?? '15m', 900);
return jwt.sign({ sub: userId, email }, secret, { expiresIn }); return jwt.sign({ sub: userId, email }, secret, { expiresIn });
} }
function getAccessTokenExpiresInSeconds(): number {
return parseDurationToSeconds(process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_EXPIRES_IN ?? '15m', 900);
}
function signRefreshToken(userId: string, email: string): string { function signRefreshToken(userId: string, email: string): string {
const secret = process.env.JWT_REFRESH_SECRET as string; const secret = process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET as string;
const expiresIn = parseDurationToSeconds(process.env.JWT_REFRESH_EXPIRES_IN ?? '30d', 2592000); const expiresIn = parseDurationToSeconds(process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_EXPIRES_IN ?? '30d', 2592000);
return jwt.sign({ sub: userId, email, typ: 'refresh' }, secret, { expiresIn }); return jwt.sign({ sub: userId, email, typ: 'refresh' }, secret, { expiresIn });
} }
function signGuildAccessToken(userId: string, email: string, guildNodeId: string): string { function signGuildAccessToken(userId: string, email: string, guildNodeId: string): string {
const secret = process.env.JWT_ACCESS_SECRET as string; const secret = process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string;
const expiresIn = parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m', 900); const expiresIn = getAccessTokenExpiresInSeconds();
return jwt.sign({ sub: userId, email, gid: guildNodeId, typ: 'guild_access' }, secret, { expiresIn }); return jwt.sign({ sub: userId, email, gid: guildNodeId, typ: 'guild_access' }, secret, { expiresIn });
} }
@@ -42,29 +48,133 @@ export class AuthService {
private readonly guildNodeRepo: Repository<GuildNode>, private readonly guildNodeRepo: Repository<GuildNode>,
@InjectRepository(GuildUser) @InjectRepository(GuildUser)
private readonly guildUserRepo: Repository<GuildUser>, private readonly guildUserRepo: Repository<GuildUser>,
@InjectRepository(UserApiKey)
private readonly apiKeyRepo: Repository<UserApiKey>,
private readonly audit: AuditService, private readonly audit: AuditService,
) {} ) {}
private async getUserGuildsAndTokens(userId: string, email: string) { // Mint a long-lived API key for a user (agent machine credential).
let memberships = await this.guildUserRepo.find({ async createUserApiKey(email: string, label?: string) {
where: { userId, status: 'active' }, const user = await this.userRepo.findOne({ where: { email } });
if (!user) throw new UnauthorizedException('user not found');
const raw = `fak_${randomBytes(24).toString('hex')}`;
await this.apiKeyRepo.save(
this.apiKeyRepo.create({
userId: user.id,
apiKeyHash: await bcrypt.hash(raw, 10),
label: label ?? null,
}),
);
await this.audit.write({
action: 'auth.apikey.create',
actorId: user.id,
targetType: 'user',
targetId: user.id,
detail: JSON.stringify({ label: label ?? null }),
}); });
return { userId: user.id, email: user.email, apiKey: raw };
}
if (!memberships.length) { // Exchange an agent API key for a normal user session (same shape as login).
const activeNodes = await this.guildNodeRepo.find({ where: { status: 'active' } }); async agentLogin(apiKey: string) {
if (activeNodes.length) { if (!apiKey) throw new UnauthorizedException('missing api key');
await this.guildUserRepo.save( const keys = await this.apiKeyRepo.find();
activeNodes.map((n) => let userId: string | null = null;
this.guildUserRepo.create({ for (const k of keys) {
userId, if (await bcrypt.compare(apiKey, k.apiKeyHash)) {
guildNodeId: n.nodeId, userId = k.userId;
status: 'active', break;
}),
),
);
memberships = await this.guildUserRepo.find({ where: { userId, status: 'active' } });
} }
} }
if (!userId) throw new UnauthorizedException('invalid api key');
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new UnauthorizedException('invalid api key');
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.agent_login',
actorId: user.id,
targetType: 'user',
targetId: user.id,
detail: null,
});
const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email);
return {
accessToken,
refreshToken,
tokenType: 'Bearer',
expiresIn: getAccessTokenExpiresInSeconds(),
user: { id: user.id, email: user.email, name: user.name ?? user.email },
guilds,
guildAccessTokens,
};
}
// OIDC: find a user by email or auto-provision one (no usable password —
// OIDC users never password-login). Returns the userId.
async provisionOidcUser(email: string, name?: string): Promise<string> {
const e = String(email ?? '').trim();
if (!e) throw new UnauthorizedException('oidc: missing email claim');
let user = await this.userRepo.findOne({ where: { email: e } });
if (!user) {
const passwordHash = await bcrypt.hash(`oidc!${randomBytes(24).toString('hex')}`, 10);
user = await this.userRepo.save(
this.userRepo.create({
email: e,
name: (name ?? '').trim() || e,
passwordHash,
refreshTokenHash: null,
}),
);
await this.audit.write({
action: 'auth.oidc.provision',
actorId: user.id,
targetType: 'user',
targetId: user.id,
detail: JSON.stringify({ email: e }),
});
}
return user.id;
}
// Issue a full session for an already-authenticated userId (same shape as
// login()). Used by the OIDC ticket exchange.
async issueSessionForUserId(userId: string) {
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new UnauthorizedException('user not found');
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.oidc.login',
actorId: user.id,
targetType: 'user',
targetId: user.id,
detail: null,
});
const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email);
return {
accessToken,
refreshToken,
tokenType: 'Bearer',
expiresIn: getAccessTokenExpiresInSeconds(),
user: { id: user.id, email: user.email, name: user.name ?? user.email },
guilds,
guildAccessTokens,
};
}
private async getUserGuildsAndTokens(userId: string, email: string) {
const memberships = await this.guildUserRepo.find({
where: { userId, status: 'active' },
});
const nodeIds = memberships.map((x) => x.guildNodeId); const nodeIds = memberships.map((x) => x.guildNodeId);
if (!nodeIds.length) { if (!nodeIds.length) {
@@ -88,11 +198,64 @@ export class AuthService {
guildNodeId: g.nodeId, guildNodeId: g.nodeId,
token: signGuildAccessToken(userId, email, g.nodeId), token: signGuildAccessToken(userId, email, g.nodeId),
tokenType: 'Bearer', tokenType: 'Bearer',
expiresIn: getAccessTokenExpiresInSeconds(),
})); }));
return { guilds, guildAccessTokens }; return { guilds, guildAccessTokens };
} }
// -------------------------------------------------------------------
// Center-scoped admin (at most one per deployment).
//
// setAdmin / clearAdmin are reachable only via cli, not HTTP — admin
// promotion bypasses every HTTP gate (the admin observes ALL triage
// traffic) so we don't want it tunneled through any token / api-key.
//
// getAdminEmail is exposed to guild backends (api-key auth) so each
// guild can mark the admin as a triage observer.
// -------------------------------------------------------------------
async getAdminEmail(): Promise<{ email: string; userId: string } | null> {
const admin = await this.userRepo.findOne({ where: { isAdmin: true } });
if (!admin) return null;
return { email: admin.email, userId: admin.id };
}
async setAdmin(email: string): Promise<{ email: string; userId: string }> {
const target = await this.userRepo.findOne({ where: { email } });
if (!target) {
throw new UnauthorizedException(`user ${email} not found`);
}
// Enforce at-most-one-admin: clear every existing admin row first
// (TypeORM rejects an empty-where update, so target by isAdmin=true
// — the no-op cost when no rows match is one cheap query), then set
// the target. Two UPDATEs in one txn so a peer never sees two admins.
await this.userRepo.manager.transaction(async (txn) => {
await txn.update(User, { isAdmin: true }, { isAdmin: false });
await txn.update(User, { id: target.id }, { isAdmin: true });
});
await this.audit.write({
action: 'admin.set',
actorId: target.id,
targetType: 'user',
targetId: target.id,
detail: JSON.stringify({ email: target.email }),
});
return { email: target.email, userId: target.id };
}
async clearAdmin(): Promise<{ cleared: number }> {
const res = await this.userRepo.update({ isAdmin: true }, { isAdmin: false });
await this.audit.write({
action: 'admin.clear',
actorId: 'cli',
targetType: 'user',
targetId: 'all',
detail: JSON.stringify({ cleared: res.affected ?? 0 }),
});
return { cleared: res.affected ?? 0 };
}
async register(input: RegisterDto) { async register(input: RegisterDto) {
const exists = await this.userRepo.findOne({ where: { email: input.email } }); const exists = await this.userRepo.findOne({ where: { email: input.email } });
if (exists) { if (exists) {
@@ -102,6 +265,7 @@ export class AuthService {
const passwordHash = await bcrypt.hash(input.password, 10); const passwordHash = await bcrypt.hash(input.password, 10);
const user = this.userRepo.create({ const user = this.userRepo.create({
email: input.email, email: input.email,
name: input.email,
passwordHash, passwordHash,
refreshTokenHash: null, refreshTokenHash: null,
}); });
@@ -118,6 +282,7 @@ export class AuthService {
return { return {
id: saved.id, id: saved.id,
email: saved.email, email: saved.email,
name: saved.name ?? saved.email,
createdAt: saved.createdAt, createdAt: saved.createdAt,
}; };
} }
@@ -148,15 +313,48 @@ export class AuthService {
accessToken, accessToken,
refreshToken, refreshToken,
tokenType: 'Bearer', tokenType: 'Bearer',
expiresIn: getAccessTokenExpiresInSeconds(),
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name ?? user.email,
}, },
guilds, guilds,
guildAccessTokens, guildAccessTokens,
}; };
} }
async getMe(accessToken: string) {
const payload = this.verifyCenterAccessToken(accessToken);
const userId = String(payload.sub ?? '');
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new UnauthorizedException('user not found');
return { id: user.id, email: user.email, name: user.name ?? user.email };
}
async updateMe(accessToken: string, name: string) {
const payload = this.verifyCenterAccessToken(accessToken);
const userId = String(payload.sub ?? '');
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new UnauthorizedException('user not found');
const trimmed = name.trim();
if (!trimmed) throw new ConflictException('name must not be empty');
user.name = trimmed;
await this.userRepo.save(user);
await this.audit.write({
action: 'auth.update_profile',
actorId: user.id,
targetType: 'user',
targetId: user.id,
detail: JSON.stringify({ name: trimmed }),
});
return { id: user.id, email: user.email, name: user.name };
}
async listMyGuilds(accessToken: string) { async listMyGuilds(accessToken: string) {
const payload = this.verifyCenterAccessToken(accessToken); const payload = this.verifyCenterAccessToken(accessToken);
const userId = String(payload.sub ?? ''); const userId = String(payload.sub ?? '');
@@ -164,9 +362,88 @@ export class AuthService {
return this.getUserGuildsAndTokens(userId, email); return this.getUserGuildsAndTokens(userId, email);
} }
async joinGuild(accessToken: string, guildNodeId: string) {
const payload = this.verifyCenterAccessToken(accessToken);
const userId = String(payload.sub ?? '');
if (!userId) throw new UnauthorizedException('invalid access token');
const node = await this.guildNodeRepo.findOne({ where: { nodeId: guildNodeId, status: 'active' } });
if (!node) throw new UnauthorizedException('guild node not found or inactive');
const existed = await this.guildUserRepo.findOne({ where: { userId, guildNodeId } });
if (!existed) {
await this.guildUserRepo.save(
this.guildUserRepo.create({ userId, guildNodeId, status: 'active' }),
);
} else if (existed.status !== 'active') {
existed.status = 'active';
await this.guildUserRepo.save(existed);
}
return { status: 'ok' as const };
}
async listGuildMembers(accessToken: string, guildNodeId: string) {
const payload = this.verifyCenterAccessToken(accessToken);
const userId = String(payload.sub ?? '');
if (!userId) throw new UnauthorizedException('invalid access token');
const selfMembership = await this.guildUserRepo.findOne({ where: { userId, guildNodeId, status: 'active' } });
if (!selfMembership) throw new UnauthorizedException('not a guild member');
const members = await this.guildUserRepo.find({ where: { guildNodeId, status: 'active' } });
const userIds = [...new Set(members.map((m) => m.userId))];
if (!userIds.length) return [];
const users = await this.userRepo
.createQueryBuilder('u')
.where('u.id IN (:...userIds)', { userIds })
.getMany();
const userMap = new Map(users.map((u) => [u.id, u]));
return members
.map((m) => {
const u = userMap.get(m.userId);
return {
userId: m.userId,
email: u?.email ?? '',
name: u?.name ?? u?.email ?? '',
status: m.status,
};
})
.filter((x) => !!x.email);
}
// Resolve display names (or emails) to userIds, scoped to a guild node's
// active members. Called by guild nodes (api-key auth). Unresolved names
// are omitted from the result.
async resolveNames(
guildNodeId: string,
names: string[],
): Promise<{ resolved: Record<string, string> }> {
const wanted = [...new Set(names.map((n) => String(n ?? '').trim()).filter(Boolean))];
if (!guildNodeId || !wanted.length) return { resolved: {} };
const members = await this.guildUserRepo.find({ where: { guildNodeId, status: 'active' } });
const userIds = [...new Set(members.map((m) => m.userId))];
if (!userIds.length) return { resolved: {} };
const users = await this.userRepo
.createQueryBuilder('u')
.where('u.id IN (:...userIds)', { userIds })
.getMany();
const resolved: Record<string, string> = {};
for (const name of wanted) {
const hit = users.find((u) => u.name === name) ?? users.find((u) => u.email === name);
if (hit) resolved[name] = hit.id;
}
return { resolved };
}
verifyCenterAccessToken(accessToken: string): jwt.JwtPayload { verifyCenterAccessToken(accessToken: string): jwt.JwtPayload {
try { try {
const payload = jwt.verify(accessToken, process.env.JWT_ACCESS_SECRET as string) as jwt.JwtPayload; const payload = jwt.verify(accessToken, process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string) as jwt.JwtPayload;
if (!payload?.sub) throw new Error('invalid'); if (!payload?.sub) throw new Error('invalid');
return payload; return payload;
} catch { } catch {
@@ -177,7 +454,7 @@ export class AuthService {
async introspectGuildToken(token: string, guildNodeId: string) { async introspectGuildToken(token: string, guildNodeId: string) {
let payload: jwt.JwtPayload; let payload: jwt.JwtPayload;
try { try {
payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET as string) as jwt.JwtPayload; payload = jwt.verify(token, process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string) as jwt.JwtPayload;
} catch { } catch {
return { active: false }; return { active: false };
} }
@@ -206,7 +483,7 @@ export class AuthService {
async refresh(refreshToken: string) { async refresh(refreshToken: string) {
let payload: jwt.JwtPayload; let payload: jwt.JwtPayload;
try { try {
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload; payload = jwt.verify(refreshToken, process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
} catch { } catch {
throw new UnauthorizedException('invalid refresh token'); throw new UnauthorizedException('invalid refresh token');
} }
@@ -237,13 +514,14 @@ export class AuthService {
accessToken: newAccessToken, accessToken: newAccessToken,
refreshToken: newRefreshToken, refreshToken: newRefreshToken,
tokenType: 'Bearer', tokenType: 'Bearer',
expiresIn: getAccessTokenExpiresInSeconds(),
}; };
} }
async logout(refreshToken: string) { async logout(refreshToken: string) {
let payload: jwt.JwtPayload; let payload: jwt.JwtPayload;
try { try {
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET as string) as jwt.JwtPayload; payload = jwt.verify(refreshToken, process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET as string) as jwt.JwtPayload;
} catch { } catch {
return { status: 'ok' }; return { status: 'ok' };
} }

View File

@@ -0,0 +1,8 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateMeDto {
@IsString()
@MinLength(1)
@MaxLength(120)
name!: string;
}

View File

@@ -0,0 +1,51 @@
import { Body, Controller, Get, Post, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import { Public } from '../common/public.decorator.js';
import { OidcService } from './oidc.service.js';
@Public()
@Controller('auth/oidc')
export class OidcController {
constructor(private readonly oidc: OidcService) {}
// Frontend asks whether to show the SSO button.
@Get('status')
async status() {
return { enabled: await this.oidc.isEnabled() };
}
// Browser hits this; we 302 to the IdP authorize endpoint.
@Get('start')
async start(@Res() res: Response) {
const url = await this.oidc.buildAuthorizeUrl();
res.redirect(url);
}
// IdP redirects back here with ?code&state. We mint a one-time ticket
// and bounce the browser to the SPA with it in the URL fragment (so the
// ticket never lands in server/proxy access logs).
@Get('callback')
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Res() res: Response,
) {
try {
const { ticket, redirect } = await this.oidc.handleCallback(code, state);
const sep = redirect.includes('#') ? '&' : '#';
res.redirect(`${redirect}${sep}oidc_ticket=${encodeURIComponent(ticket)}`);
} catch (e) {
const msg = e instanceof Error ? e.message : 'oidc error';
const c = await this.oidc.getConfig();
const base = c?.postLoginRedirect || '/';
const sep = base.includes('#') ? '&' : '#';
res.redirect(`${base}${sep}oidc_error=${encodeURIComponent(msg)}`);
}
}
// SPA exchanges the one-time ticket for a full Fabric session.
@Post('exchange')
async exchange(@Body() body: { ticket?: string }) {
return this.oidc.exchangeTicket(String(body?.ticket ?? ''));
}
}

200
src/auth/oidc.service.ts Normal file
View File

@@ -0,0 +1,200 @@
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import jwt from 'jsonwebtoken';
import { createHash, randomBytes } from 'crypto';
import { OidcConfig } from '../entities/oidc-config.entity.js';
import { AuthService } from './auth.service.js';
const ROW_ID = 'default';
type Discovery = {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
};
function b64url(buf: Buffer): string {
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
@Injectable()
export class OidcService {
private discoveryCache: { issuer: string; doc: Discovery; at: number } | null = null;
constructor(
@InjectRepository(OidcConfig)
private readonly repo: Repository<OidcConfig>,
private readonly auth: AuthService,
) {}
private stateSecret(): string {
return process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string;
}
async getConfig(): Promise<OidcConfig | null> {
return this.repo.findOne({ where: { id: ROW_ID } });
}
async setConfig(patch: Partial<Omit<OidcConfig, 'id' | 'updatedAt'>>): Promise<OidcConfig> {
let row = await this.repo.findOne({ where: { id: ROW_ID } });
if (!row) row = this.repo.create({ id: ROW_ID });
Object.assign(row, patch);
return this.repo.save(row);
}
async isEnabled(): Promise<boolean> {
const c = await this.getConfig();
return !!(c && c.enabled && c.issuer && c.clientId && c.clientSecret && c.redirectUri);
}
private async requireEnabledConfig(): Promise<OidcConfig> {
const c = await this.getConfig();
if (!c || !c.enabled || !c.issuer || !c.clientId || !c.clientSecret || !c.redirectUri) {
throw new BadRequestException('oidc is not configured/enabled');
}
return c;
}
private async discover(issuer: string): Promise<Discovery> {
const now = Date.now();
if (this.discoveryCache && this.discoveryCache.issuer === issuer && now - this.discoveryCache.at < 3600_000) {
return this.discoveryCache.doc;
}
const url = issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
let res: Response;
try {
res = await fetch(url);
} catch {
throw new BadRequestException('oidc: issuer unreachable (discovery failed)');
}
if (!res.ok) throw new BadRequestException(`oidc discovery failed: ${res.status}`);
const doc = (await res.json()) as Discovery;
if (!doc.authorization_endpoint || !doc.token_endpoint) {
throw new BadRequestException('oidc discovery missing endpoints');
}
this.discoveryCache = { issuer, doc, at: now };
return doc;
}
// Build the IdP authorize URL. PKCE verifier + nonce are sealed into a
// short-lived signed `state` JWT (no server-side session needed).
async buildAuthorizeUrl(): Promise<string> {
const c = await this.requireEnabledConfig();
const doc = await this.discover(c.issuer);
const codeVerifier = b64url(randomBytes(32));
const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest());
const nonce = b64url(randomBytes(16));
const state = jwt.sign({ cv: codeVerifier, n: nonce, typ: 'oidc_state' }, this.stateSecret(), {
expiresIn: 600,
});
const p = new URLSearchParams({
response_type: 'code',
client_id: c.clientId,
redirect_uri: c.redirectUri,
scope: c.scopes || 'openid email profile',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${doc.authorization_endpoint}?${p.toString()}`;
}
// Handle the IdP callback: validate state, exchange the code, resolve the
// user's email/name, provision/find the user, and return a one-time
// ticket (JWT, 60s) plus the post-login redirect URL.
async handleCallback(code: string, state: string): Promise<{ ticket: string; redirect: string }> {
if (!code || !state) throw new BadRequestException('oidc: missing code/state');
const c = await this.requireEnabledConfig();
let st: { cv?: string; typ?: string };
try {
st = jwt.verify(state, this.stateSecret()) as { cv?: string; typ?: string };
} catch {
throw new UnauthorizedException('oidc: invalid state');
}
if (st.typ !== 'oidc_state' || !st.cv) throw new UnauthorizedException('oidc: invalid state');
const doc = await this.discover(c.issuer);
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: c.redirectUri,
client_id: c.clientId,
client_secret: c.clientSecret,
code_verifier: st.cv,
});
let tokRes: Response;
try {
tokRes = await fetch(doc.token_endpoint, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body: body.toString(),
});
} catch {
throw new UnauthorizedException('oidc: token endpoint unreachable');
}
if (!tokRes.ok) {
throw new UnauthorizedException(`oidc: token exchange failed (${tokRes.status})`);
}
const tok = (await tokRes.json()) as { access_token?: string; id_token?: string };
const claims = await this.resolveClaims(doc, tok);
const email = claims.email;
if (!email) throw new UnauthorizedException('oidc: no email claim');
const userId = await this.auth.provisionOidcUser(email, claims.name);
const ticket = jwt.sign({ sub: userId, typ: 'oidc_ticket' }, this.stateSecret(), { expiresIn: 60 });
return { ticket, redirect: c.postLoginRedirect };
}
// Prefer userinfo (verified over TLS with the fresh access_token); fall
// back to decoding id_token payload for email/name/sub.
private async resolveClaims(
doc: Discovery,
tok: { access_token?: string; id_token?: string },
): Promise<{ email?: string; name?: string }> {
if (doc.userinfo_endpoint && tok.access_token) {
try {
const r = await fetch(doc.userinfo_endpoint, {
headers: { authorization: `Bearer ${tok.access_token}` },
});
if (r.ok) {
const u = (await r.json()) as { email?: string; name?: string; preferred_username?: string };
if (u.email) return { email: u.email, name: u.name ?? u.preferred_username };
}
} catch {
// fall through to id_token
}
}
if (tok.id_token) {
const part = tok.id_token.split('.')[1];
if (part) {
try {
const p = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as {
email?: string;
name?: string;
preferred_username?: string;
};
return { email: p.email, name: p.name ?? p.preferred_username };
} catch {
// ignore
}
}
}
return {};
}
// Redeem the one-time ticket for a full Fabric session (login shape).
async exchangeTicket(ticket: string) {
let t: { sub?: string; typ?: string };
try {
t = jwt.verify(ticket, this.stateSecret()) as { sub?: string; typ?: string };
} catch {
throw new UnauthorizedException('oidc: invalid or expired ticket');
}
if (t.typ !== 'oidc_ticket' || !t.sub) throw new UnauthorizedException('oidc: invalid ticket');
return this.auth.issueSessionForUserId(t.sub);
}
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseDurationToSeconds } from './token.util'; import { parseDurationToSeconds } from './token.util.js';
describe('parseDurationToSeconds', () => { describe('parseDurationToSeconds', () => {
it('parses time units', () => { it('parses time units', () => {

141
src/cli.ts Normal file
View File

@@ -0,0 +1,141 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { AuthService } from './auth/auth.service.js';
import { OidcService } from './auth/oidc.service.js';
import { NodeAdminService } from './nodes/node-admin.service.js';
function getArg(flag: string): string | null {
const idx = process.argv.indexOf(flag);
if (idx === -1) return null;
return process.argv[idx + 1] ?? null;
}
function printUsageAndExit(): never {
console.error('Usage:');
console.error(' node dist/cli.js user create --email <email> --password <password>');
console.error(' node dist/cli.js user apikey --email <email> [--label <label>]');
console.error(' node dist/cli.js user set-admin --email <email>');
console.error(' node dist/cli.js user clear-admin');
console.error(' node dist/cli.js user show-admin');
console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>');
console.error(
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' +
' [--callback-url <url>] [--post-login-redirect <url>] [--scopes "openid email profile"]\n' +
' [--enabled true|false] (sets provided fields; prints config with secret masked)',
);
process.exit(1);
}
async function main() {
const [subject, action] = process.argv.slice(2);
if (!subject || !action) printUsageAndExit();
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
try {
if (subject === 'user' && action === 'create') {
const email = getArg('--email');
const password = getArg('--password');
if (!email || !password) printUsageAndExit();
const auth = app.get(AuthService);
const user = await auth.register({ email, password });
process.stdout.write(JSON.stringify({ ok: true, user }) + '\n');
return;
}
if (subject === 'user' && action === 'apikey') {
const email = getArg('--email');
const label = getArg('--label');
if (!email) printUsageAndExit();
const auth = app.get(AuthService);
const res = await auth.createUserApiKey(email, label ?? undefined);
process.stdout.write(JSON.stringify({ ok: true, ...res }) + '\n');
return;
}
if (subject === 'user' && action === 'set-admin') {
const email = getArg('--email');
if (!email) printUsageAndExit();
const auth = app.get(AuthService);
const res = await auth.setAdmin(email);
process.stdout.write(JSON.stringify({ ok: true, admin: res }) + '\n');
return;
}
if (subject === 'user' && action === 'clear-admin') {
const auth = app.get(AuthService);
const res = await auth.clearAdmin();
process.stdout.write(JSON.stringify({ ok: true, ...res }) + '\n');
return;
}
if (subject === 'user' && action === 'show-admin') {
const auth = app.get(AuthService);
const res = await auth.getAdminEmail();
process.stdout.write(JSON.stringify({ ok: true, admin: res }) + '\n');
return;
}
if (subject === 'node' && action === 'register') {
const nodeId = getArg('--node-id');
const name = getArg('--name');
const endpoint = getArg('--endpoint');
if (!nodeId || !name || !endpoint) printUsageAndExit();
const nodes = app.get(NodeAdminService);
const result = await nodes.registerNode({ nodeId, name, endpoint });
process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n');
return;
}
if (subject === 'config' && action === 'oidc') {
const oidc = app.get(OidcService);
const patch: Record<string, unknown> = {};
const issuer = getArg('--issuer');
if (issuer !== null) patch.issuer = issuer;
const clientId = getArg('--client-id');
if (clientId !== null) patch.clientId = clientId;
const clientSecret = getArg('--client-secret');
if (clientSecret !== null) patch.clientSecret = clientSecret;
const callbackUrl = getArg('--callback-url');
if (callbackUrl !== null) patch.redirectUri = callbackUrl;
const postLogin = getArg('--post-login-redirect');
if (postLogin !== null) patch.postLoginRedirect = postLogin;
const scopes = getArg('--scopes');
if (scopes !== null) patch.scopes = scopes;
const enabled = getArg('--enabled');
if (enabled !== null) patch.enabled = enabled === 'true';
const saved = await oidc.setConfig(patch);
process.stdout.write(
JSON.stringify({
ok: true,
config: {
issuer: saved.issuer,
clientId: saved.clientId,
clientSecret: saved.clientSecret ? '***set***' : '',
redirectUri: saved.redirectUri,
postLoginRedirect: saved.postLoginRedirect,
scopes: saved.scopes,
enabled: saved.enabled,
},
}) + '\n',
);
return;
}
printUsageAndExit();
} finally {
await app.close();
}
}
void main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : 'unknown error';
process.stderr.write(JSON.stringify({ ok: false, error: message }) + '\n');
process.exit(1);
});

View File

@@ -1,31 +1,36 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { GuildNode } from '../entities/guild-node.entity'; import { GuildNode } from '../entities/guild-node.entity.js';
import { IS_PUBLIC_KEY } from './public.decorator.js';
// The authenticated guild node, attached to the request by this guard so
// downstream handlers can enforce node-ownership (a node may only act on
// its own nodeId / introspect tokens for its own guild).
export type AuthedGuildNode = { id: string; nodeId: string };
@Injectable() @Injectable()
export class CenterApiKeyGuard implements CanActivate { export class CenterApiKeyGuard implements CanActivate {
constructor( constructor(
@InjectRepository(GuildNode) @InjectRepository(GuildNode)
private readonly nodeRepo: Repository<GuildNode>, private readonly nodeRepo: Repository<GuildNode>,
private readonly reflector: Reflector,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const req = context.switchToHttp().getRequest<{ const req = context.switchToHttp().getRequest<{
path?: string;
method?: string;
headers: Record<string, string | string[] | undefined>; headers: Record<string, string | string[] | undefined>;
guildNode?: AuthedGuildNode;
}>(); }>();
const path = req.path ?? '';
const method = (req.method ?? 'GET').toUpperCase();
// only guild registration is exempt from API key; it is protected by HMAC secret
if (method === 'POST' && (path === '/nodes/register' || path.endsWith('/nodes/register'))) {
return true;
}
const received = req.headers['x-api-key']; const received = req.headers['x-api-key'];
const apiKey = Array.isArray(received) ? received[0] : received; const apiKey = Array.isArray(received) ? received[0] : received;
if (!apiKey) throw new UnauthorizedException('missing api key'); if (!apiKey) throw new UnauthorizedException('missing api key');
@@ -34,10 +39,14 @@ export class CenterApiKeyGuard implements CanActivate {
for (const node of nodes) { for (const node of nodes) {
if (!node.apiKeyHash) continue; if (!node.apiKeyHash) continue;
const ok = await bcrypt.compare(apiKey, node.apiKeyHash); const ok = await bcrypt.compare(apiKey, node.apiKeyHash);
if (ok) return true; if (ok) {
// Identify which node this key belongs to so handlers can enforce
// that a node only acts within its own scope (Center C2/C3).
req.guildNode = { id: node.id, nodeId: node.nodeId };
return true;
}
} }
throw new UnauthorizedException('invalid api key'); throw new UnauthorizedException('invalid api key');
} }
} }

View File

@@ -1,6 +1,8 @@
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { Public } from './public.decorator.js';
@Public()
@Controller('healthz') @Controller('healthz')
export class HealthController { export class HealthController {
constructor(private readonly dataSource: DataSource) {} constructor(private readonly dataSource: DataSource) {}

View File

@@ -1,5 +1,5 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { MetricsService } from './metrics.service'; import { MetricsService } from './metrics.service.js';
@Controller('metrics') @Controller('metrics')
export class MetricsController { export class MetricsController {

View File

@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
// Routes annotated with @Public() skip the global CenterApiKeyGuard
// (api-key + node-identity) check. This is metadata-driven on purpose:
// the previous string-matching whitelist (path.endsWith(...)) was a
// bypass surface. Only the route's own decorator opens it.
export const IS_PUBLIC_KEY = 'fabric:isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -1,6 +1,6 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { MetricsService } from './metrics.service'; import { MetricsService } from './metrics.service.js';
type ReqWithId = Request & { requestId?: string }; type ReqWithId = Request & { requestId?: string };

View File

@@ -1,6 +1,6 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { DataSource, DataSourceOptions } from 'typeorm'; import { DataSource, DataSourceOptions } from 'typeorm';
import { buildTypeOrmConfig } from './database.config'; import { buildTypeOrmConfig } from './database.config.js';
const cfg = buildTypeOrmConfig(); const cfg = buildTypeOrmConfig();

View File

@@ -1,17 +1,19 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { User } from './entities/user.entity'; import { User } from './entities/user.entity.js';
import { GuildNode } from './entities/guild-node.entity'; import { GuildNode } from './entities/guild-node.entity.js';
import { AuditLog } from './entities/audit-log.entity'; import { AuditLog } from './entities/audit-log.entity.js';
import { GuildUser } from './entities/guild-user.entity'; import { GuildUser } from './entities/guild-user.entity.js';
import { UserApiKey } from './entities/user-api-key.entity.js';
import { OidcConfig } from './entities/oidc-config.entity.js';
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
type: 'mysql', type: 'mysql',
host: process.env.DB_HOST ?? 'mysql-center', host: process.env.FABRIC_BACKEND_CENTER_DB_HOST ?? 'mysql-center',
port: Number(process.env.DB_PORT ?? 3306), port: Number(process.env.FABRIC_BACKEND_CENTER_DB_PORT ?? 3306),
username: process.env.DB_USER ?? 'fabric', username: process.env.FABRIC_BACKEND_CENTER_DB_USER ?? 'fabric',
password: process.env.DB_PASSWORD ?? 'fabric', password: process.env.FABRIC_BACKEND_CENTER_DB_PASSWORD ?? 'fabric',
database: process.env.DB_NAME ?? 'fabric_center', database: process.env.FABRIC_BACKEND_CENTER_DB_NAME ?? 'fabric_center',
entities: [User, GuildNode, GuildUser, AuditLog], entities: [User, GuildNode, GuildUser, AuditLog, UserApiKey, OidcConfig],
synchronize: (process.env.DB_SYNC ?? 'true') === 'true', synchronize: (process.env.FABRIC_BACKEND_CENTER_DB_SYNC ?? 'true') === 'true',
logging: (process.env.DB_LOGGING ?? 'false') === 'true', logging: (process.env.FABRIC_BACKEND_CENTER_DB_LOGGING ?? 'false') === 'true',
}); });

View File

@@ -0,0 +1,37 @@
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
// Single-row OIDC provider configuration, managed via the CLI
// `config-oidc`. id is always 'default'.
@Entity('oidc_config')
export class OidcConfig {
@PrimaryColumn({ type: 'varchar', length: 16 })
id!: string; // always 'default'
@Column({ type: 'varchar', length: 512, default: '' })
issuer!: string;
@Column({ type: 'varchar', length: 256, default: '' })
clientId!: string;
@Column({ type: 'varchar', length: 512, default: '' })
clientSecret!: string;
// Center's own callback URL registered at the IdP, e.g.
// https://fabric-api.example/api/auth/oidc/callback
@Column({ type: 'varchar', length: 512, default: '' })
redirectUri!: string;
// Where to send the browser back after a successful login (the SPA),
// e.g. https://fabric.example/oidc
@Column({ type: 'varchar', length: 512, default: '' })
postLoginRedirect!: string;
@Column({ type: 'varchar', length: 256, default: 'openid email profile' })
scopes!: string;
@Column({ type: 'boolean', default: false })
enabled!: boolean;
@UpdateDateColumn()
updatedAt!: Date;
}

View File

@@ -0,0 +1,22 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
// Machine credential for an agent: a long-lived API key that maps to a user.
// The plugin exchanges it for a normal user session (POST /auth/agent/login).
@Entity('user_api_keys')
export class UserApiKey {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index()
@Column({ name: 'user_id', type: 'varchar', length: 64 })
userId!: string;
@Column({ name: 'api_key_hash', type: 'varchar', length: 255 })
apiKeyHash!: string;
@Column({ type: 'varchar', length: 120, nullable: true })
label!: string | null;
@CreateDateColumn()
createdAt!: Date;
}

View File

@@ -8,12 +8,28 @@ export class User {
@Column({ unique: true }) @Column({ unique: true })
email!: string; email!: string;
@Column({ type: 'varchar', length: 120, nullable: true })
name!: string | null;
@Column() @Column()
passwordHash!: string; passwordHash!: string;
@Column({ type: 'varchar', length: 255, nullable: true }) @Column({ type: 'varchar', length: 255, nullable: true })
refreshTokenHash!: string | null; refreshTokenHash!: string | null;
/**
* Center-scoped admin flag. At most one user should have this set at a
* time; the cli `user set-admin` enforces single-admin by clearing all
* other admins before promoting the target user. Guild backends learn
* the current admin via `GET /admin-email`.
*
* Use: triage channels deliver every message to the admin as a silent
* observer (wake=false) regardless of on-duty / mention status, so a
* single human can audit all triage traffic.
*/
@Column({ type: 'tinyint', default: 0, name: 'isAdmin' })
isAdmin!: boolean;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;
} }

View File

@@ -18,7 +18,7 @@ describe('center integration (mysql + api)', () => {
let app: INestApplication; let app: INestApplication;
beforeAll(async () => { beforeAll(async () => {
const { AppModule } = await import('./app.module'); const { AppModule } = await import('./app.module.js');
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
}).compile(); }).compile();

View File

@@ -2,9 +2,9 @@ import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module'; import { AppModule } from './app.module.js';
import { createRequestContextMiddleware } from './common/request-context.middleware'; import { createRequestContextMiddleware } from './common/request-context.middleware.js';
import { MetricsService } from './common/metrics.service'; import { MetricsService } from './common/metrics.service.js';
function requireEnv(name: string): string { function requireEnv(name: string): string {
const value = process.env[name]; const value = process.env[name];
@@ -15,20 +15,42 @@ function requireEnv(name: string): string {
} }
function validateEnv(): void { function validateEnv(): void {
requireEnv('DB_HOST'); requireEnv('FABRIC_BACKEND_CENTER_DB_HOST');
requireEnv('DB_PORT'); requireEnv('FABRIC_BACKEND_CENTER_DB_PORT');
requireEnv('DB_USER'); requireEnv('FABRIC_BACKEND_CENTER_DB_USER');
requireEnv('DB_PASSWORD'); requireEnv('FABRIC_BACKEND_CENTER_DB_PASSWORD');
requireEnv('DB_NAME'); requireEnv('FABRIC_BACKEND_CENTER_DB_NAME');
requireEnv('CENTER_SHARED_SECRET'); requireEnv('FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET');
requireEnv('JWT_ACCESS_SECRET'); requireEnv('FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET');
requireEnv('JWT_REFRESH_SECRET');
} }
async function bootstrap() { async function bootstrap() {
validateEnv(); validateEnv();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const corsOrigins = (process.env.FABRIC_BACKEND_CENTER_CORS_ORIGINS ?? '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
app.enableCors({
origin: (origin, callback) => {
// no Origin header: curl/server-to-server/most desktop local calls
if (!origin) return callback(null, true);
// desktop/electron local file origin
if (origin === 'null') return callback(null, true);
// empty allowlist => allow all origins
if (!corsOrigins.length) return callback(null, true);
if (corsOrigins.includes(origin)) return callback(null, true);
return callback(new Error('CORS origin not allowed'), false);
},
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-client-name', 'x-request-id', 'x-api-key'],
credentials: false,
});
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
const metrics = app.get(MetricsService); const metrics = app.get(MetricsService);
app.use(createRequestContextMiddleware('center', metrics)); app.use(createRequestContextMiddleware('center', metrics));
@@ -48,7 +70,7 @@ async function bootstrap() {
const swaggerDoc = SwaggerModule.createDocument(app, swaggerConfig); const swaggerDoc = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, swaggerDoc); SwaggerModule.setup('docs', app, swaggerDoc);
const port = process.env.PORT ? Number(process.env.PORT) : 7001; const port = process.env.FABRIC_BACKEND_CENTER_PORT ? Number(process.env.FABRIC_BACKEND_CENTER_PORT) : 7001;
await app.listen(port); await app.listen(port);
console.log(`Fabric.Backend.Center listening on :${port}`); console.log(`Fabric.Backend.Center listening on :${port}`);
} }

View File

@@ -0,0 +1,59 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { GuildNode } from '../entities/guild-node.entity.js';
import { AuditService } from '../audit/audit.service.js';
@Injectable()
export class NodeAdminService {
constructor(
@InjectRepository(GuildNode)
private readonly nodeRepo: Repository<GuildNode>,
private readonly audit: AuditService,
) {}
async registerNode(input: { nodeId: string; name: string; endpoint: string }) {
const existedByNodeId = await this.nodeRepo.findOne({ where: { nodeId: input.nodeId } });
if (existedByNodeId) {
throw new ConflictException('nodeId already exists');
}
const existedByEndpoint = await this.nodeRepo.findOne({ where: { endpoint: input.endpoint } });
if (existedByEndpoint) {
throw new ConflictException('endpoint already exists');
}
const node = this.nodeRepo.create({
nodeId: input.nodeId,
name: input.name,
endpoint: input.endpoint,
status: 'active',
apiKeyHash: null,
});
const rawApiKey = `gk_${randomBytes(24).toString('hex')}`;
node.apiKeyHash = await bcrypt.hash(rawApiKey, 10);
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, via: 'cli' }),
});
return {
node: {
id: saved.id,
nodeId: saved.nodeId,
name: saved.name,
endpoint: saved.endpoint,
status: saved.status,
},
apiKey: rawApiKey,
};
}
}

View File

@@ -1,34 +1,23 @@
import { import {
Body, Body,
ConflictException,
Controller, Controller,
DefaultValuePipe, DefaultValuePipe,
ForbiddenException, ForbiddenException,
Get, Get,
Headers,
HttpException,
NotFoundException, NotFoundException,
Param, Param,
ParseIntPipe, ParseIntPipe,
Patch, Patch,
Post, Post,
Query, Query,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs'; import { GuildNode } from '../entities/guild-node.entity.js';
import { randomBytes } from 'crypto'; import { AuditService } from '../audit/audit.service.js';
import { GuildNode } from '../entities/guild-node.entity'; import { UpdateNodeStatusDto } from './dto.update-node-status.dto.js';
import { AuditService } from '../audit/audit.service'; import type { AuthedGuildNode } from '../common/center-api-key.guard.js';
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') @Controller('nodes')
export class NodesController { export class NodesController {
@@ -38,93 +27,21 @@ export class NodesController {
private readonly audit: AuditService, private readonly audit: AuditService,
) {} ) {}
@Post('register') // A node may only act on itself. The guard authenticates the caller via
async register( // its api key and attaches the matching node; reject if it tries to
@Body() body: RegisterNodeDto, // touch any other nodeId (Center C3 — prevents node hijack/DoS).
@Headers('x-fabric-signature') signature?: string, private assertSelf(req: { guildNode?: AuthedGuildNode }, nodeId: string) {
@Headers('x-fabric-timestamp') timestamp?: string, if (!req.guildNode || req.guildNode.nodeId !== nodeId) {
@Headers('x-fabric-nonce') nonce?: string, throw new ForbiddenException('api key not authorized for this node');
@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',
apiKeyHash: null,
});
const rawApiKey = `gk_${randomBytes(24).toString('hex')}`;
node.apiKeyHash = await bcrypt.hash(rawApiKey, 10);
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,
},
apiKey: rawApiKey,
};
} }
@Post(':nodeId/heartbeat') @Post(':nodeId/heartbeat')
async heartbeat(@Param('nodeId') nodeId: string) { async heartbeat(
@Req() req: { guildNode?: AuthedGuildNode },
@Param('nodeId') nodeId: string,
) {
this.assertSelf(req, nodeId);
const node = await this.nodeRepo.findOne({ where: { nodeId } }); const node = await this.nodeRepo.findOne({ where: { nodeId } });
if (!node) { if (!node) {
throw new NotFoundException('node not found'); throw new NotFoundException('node not found');
@@ -152,9 +69,11 @@ export class NodesController {
@Patch(':nodeId/status') @Patch(':nodeId/status')
async updateStatus( async updateStatus(
@Req() req: { guildNode?: AuthedGuildNode },
@Param('nodeId') nodeId: string, @Param('nodeId') nodeId: string,
@Body() body: UpdateNodeStatusDto, @Body() body: UpdateNodeStatusDto,
) { ) {
this.assertSelf(req, nodeId);
const node = await this.nodeRepo.findOne({ where: { nodeId } }); const node = await this.nodeRepo.findOne({ where: { nodeId } });
if (!node) { if (!node) {
throw new NotFoundException('node not found'); throw new NotFoundException('node not found');
@@ -192,7 +111,18 @@ export class NodesController {
}); });
return { return {
items, // Never expose apiKeyHash (Center C3 — credential hash leak / offline
// brute-force). Project to an explicit safe shape.
items: items.map((n: GuildNode) => ({
id: n.id,
nodeId: n.nodeId,
name: n.name,
endpoint: n.endpoint,
status: n.status,
lastHeartbeatAt: n.lastHeartbeatAt,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
})),
page: safePage, page: safePage,
pageSize: safePageSize, pageSize: safePageSize,
total, total,

View File

@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { NodesController } from './nodes.controller'; import { NodesController } from './nodes.controller.js';
import { GuildNode } from '../entities/guild-node.entity'; import { GuildNode } from '../entities/guild-node.entity.js';
import { NodeAdminService } from './node-admin.service.js';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([GuildNode])], imports: [TypeOrmModule.forFeature([GuildNode])],
controllers: [NodesController], controllers: [NodesController],
providers: [NodeAdminService],
exports: [NodeAdminService],
}) })
export class NodesModule {} export class NodesModule {}

View File

@@ -1,7 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "NodeNext",
"target": "es2020", "moduleResolution": "NodeNext",
"target": "es2022",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"experimentalDecorators": true, "experimentalDecorators": true,