Compare commits

...

6 Commits

Author SHA1 Message Date
9e1909a7e8 refactor(guild-discovery): drop serviceEndpoint (no longer needed)
Pairs with Dialectic.Backend@5cf4302 which removes the backend-driven
broadcast that was the only consumer of serviceEndpoint. With agent-
driven recruitment broadcasts via fabric-send-message, the
service-to-service URL distinction goes away (agents use the regular
guild endpoint).

Removed:
  - GuildNode.serviceEndpoint column (TypeORM will drop on next sync)
  - GET /api/auth/me/guilds + /api/nodes response fields
  - NodeAdminService.setServiceEndpoint()
  - cli 'node set-service-endpoint' subcommand

Kept:
  - GuildNode.purpose (used by fabric-guild-list for intent-based
    channel discovery — still wanted)
2026-05-23 23:48:19 +01:00
3058bccfb6 feat(guild-discovery): add serviceEndpoint for backend-to-backend reachability
A guild's existing 'endpoint' field is its client-facing URL (browser,
remote openclaw plugin) — but in-deployment services on the same docker
network can't always reach it. Concretely, dialectic-backend on
compose_default network sees 'http://server.t3:7002' (from agent-supplied
URL) resolve to dind-network IP which it can't route to. Broadcasts
silently fail with 'connection refused'.

Adds a second URL per guild: serviceEndpoint. Used by other backends
in the same deployment (compose service name + internal port). Plumbed
through:

  - GuildNode.serviceEndpoint (varchar 255 nullable; TypeORM auto-migrates)
  - GET /api/auth/me/guilds returns serviceEndpoint per row
  - GET /api/nodes (admin) returns serviceEndpoint
  - new cli: 'node set-service-endpoint --node-id X --endpoint URL'
    (admin-only via cli — same pattern as set-purpose)

Pairs with Fabric.OpenclawPlugin's fabric-guild-list returning the new
field + workflows teaching agents to use serviceEndpoint for
announce_guild_base_url (NOT the client-facing endpoint).

E2e verified: sim recruiter agent discovered sim-guild-1's
serviceEndpoint=http://fabric-backend-guild:7002, plumbed it into
dialectic_propose_topic, all 4 lifecycle broadcasts (signup_open,
signup_closed, debating, completed) landed in the announce channel.
2026-05-23 22:21:03 +01:00
604f6556fe feat(guild-discovery): add purpose column to guild_nodes + cli set-purpose
Adds a free-form 'purpose' text field on GuildNode so admins can describe
what each guild is for (debate broadcasts / agent triage / dev sandbox /
etc.). Surfaced through:

  - GET /api/auth/me/guilds   (existing user-facing list)
  - GET /api/nodes             (existing admin-facing list)
  - new cli: node set-purpose --node-id X --purpose 'text'

Admin-only writes via CLI (HangmanLab pattern) — never HTTP. Empty string
clears. TypeORM synchronize auto-adds the column on first startup.

Motivates: agents discover the right guild by intent (purpose substring
match) before picking a channel, so dialectic/announce workflows don't
need hardcoded guild ids.
2026-05-23 19:21:49 +01:00
ca7c6b408e Merge pull request 'feat(auth): center-scoped single admin + GET /admin-email + cli' (#1) from feat/admin-user into main 2026-05-22 21:59:14 +00:00
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
7 changed files with 146 additions and 1 deletions

View File

@@ -79,6 +79,15 @@ export class AuthController {
return this.authService.agentLogin(String(body?.apiKey ?? '')); 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 // Not @Public(): requires a guild node api key. The guard attaches the
// authenticated node; a node may only introspect tokens for its own // authenticated node; a node may only introspect tokens for its own
// guild (Center C2 — prevents cross-tenant identity probing). // guild (Center C2 — prevents cross-tenant identity probing).

View File

@@ -192,6 +192,7 @@ export class AuthService {
name: n.name, name: n.name,
endpoint: n.endpoint, endpoint: n.endpoint,
status: n.status, status: n.status,
purpose: n.purpose ?? null,
})); }));
const guildAccessTokens = guilds.map((g) => ({ const guildAccessTokens = guilds.map((g) => ({
@@ -204,6 +205,58 @@ export class AuthService {
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) {

View File

@@ -15,7 +15,11 @@ function printUsageAndExit(): never {
console.error('Usage:'); console.error('Usage:');
console.error(' node dist/cli.js user create --email <email> --password <password>'); 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 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 node register --node-id <id> --name <name> --endpoint <url>');
console.error(' node dist/cli.js node set-purpose --node-id <id> --purpose <text>');
console.error( console.error(
' node dist/cli.js config oidc [--issuer <url>] [--client-id <id>] [--client-secret <s>]\n' + ' 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' + ' [--callback-url <url>] [--post-login-redirect <url>] [--scopes "openid email profile"]\n' +
@@ -52,6 +56,30 @@ async function main() {
return; 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') { if (subject === 'node' && action === 'register') {
const nodeId = getArg('--node-id'); const nodeId = getArg('--node-id');
const name = getArg('--name'); const name = getArg('--name');
@@ -64,6 +92,17 @@ async function main() {
return; return;
} }
if (subject === 'node' && action === 'set-purpose') {
const nodeId = getArg('--node-id');
const purpose = getArg('--purpose');
if (!nodeId || purpose === null) printUsageAndExit();
const nodes = app.get(NodeAdminService);
const result = await nodes.setPurpose(nodeId, purpose);
process.stdout.write(JSON.stringify({ ok: true, node: result }) + '\n');
return;
}
if (subject === 'config' && action === 'oidc') { if (subject === 'config' && action === 'oidc') {
const oidc = app.get(OidcService); const oidc = app.get(OidcService);
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};

View File

@@ -20,6 +20,15 @@ export class GuildNode {
@Column() @Column()
endpoint!: string; endpoint!: string;
// Free-form description of this guild's purpose — what it's used for,
// who its agents/users serve. Surfaced to agents via /auth/me/guilds so
// the cross-guild/cross-channel announce-target picker can find the
// right guild by intent ("which guild is about debate broadcasts?")
// without anything hard-coded into agent workflows. admin-only write
// (cli: `node set-purpose --node-id X --purpose Y`).
@Column({ type: 'text', nullable: true })
purpose!: string | null;
@Column({ type: 'varchar', length: 255, nullable: true }) @Column({ type: 'varchar', length: 255, nullable: true })
apiKeyHash!: string | null; apiKeyHash!: string | null;

View File

@@ -17,6 +17,19 @@ export class User {
@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

@@ -1,4 +1,4 @@
import { ConflictException, Injectable } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
@@ -55,5 +55,26 @@ export class NodeAdminService {
apiKey: rawApiKey, apiKey: rawApiKey,
}; };
} }
// Admin-only via cli (never HTTP): set the free-form purpose string on
// a guild node. Pass an empty string to clear it (null).
async setPurpose(nodeId: string, purpose: string) {
const node = await this.nodeRepo.findOne({ where: { nodeId } });
if (!node) throw new NotFoundException(`node ${nodeId} not found`);
const trimmed = String(purpose ?? '').trim();
node.purpose = trimmed === '' ? null : trimmed;
const saved = await this.nodeRepo.save(node);
await this.audit.write({
action: 'node.set_purpose',
targetType: 'node',
targetId: saved.nodeId,
detail: JSON.stringify({ purpose: saved.purpose, via: 'cli' }),
});
return {
nodeId: saved.nodeId,
name: saved.name,
purpose: saved.purpose,
};
}
} }

View File

@@ -119,6 +119,7 @@ export class NodesController {
name: n.name, name: n.name,
endpoint: n.endpoint, endpoint: n.endpoint,
status: n.status, status: n.status,
purpose: n.purpose ?? null,
lastHeartbeatAt: n.lastHeartbeatAt, lastHeartbeatAt: n.lastHeartbeatAt,
createdAt: n.createdAt, createdAt: n.createdAt,
updatedAt: n.updatedAt, updatedAt: n.updatedAt,