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)
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.
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.
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>
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>
- 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>
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>
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>
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>
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>
- 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>