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>
This commit is contained in:
@@ -204,6 +204,56 @@ export class AuthService {
|
||||
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 other row first, 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: 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) {
|
||||
const exists = await this.userRepo.findOne({ where: { email: input.email } });
|
||||
if (exists) {
|
||||
|
||||
Reference in New Issue
Block a user