Files
Fabric.Backend.Center/src/cli.ts
hanghang zhang 06756af8bc 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

142 lines
5.1 KiB
TypeScript

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