feat(broker+cli): apikey create/list/revoke verbs (v0.2.0 #71)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Issuance flow over WS for now (REST endpoints come next slice).
Plaintext secret returned ONCE on create — never recoverable.

- broker: 3 WS handlers (apikey_create/list/revoke), wire types in
  union, audit log on issuance + revoke
- ws-client: apiKeyCreate/List/Revoke with resolver maps, response
  dispatch
- CLI: claudemesh apikey create <label> [--cap a,b] [--topic c,d]
  [--expires ISO]; list shows status, scope, last-used; revoke by id
- policy: apikey create + revoke prompt by default (issuing or
  disabling a credential is meaningful)

Default capability set is "send,read" — least privilege for unscoped
keys (admin must explicitly opt-in).

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 02:13:12 +01:00
parent f45380d231
commit 13d691980a
7 changed files with 350 additions and 0 deletions

View File

@@ -93,6 +93,9 @@ import {
topicHistory,
markTopicRead,
appendTopicMessage,
createApiKey,
listApiKeys,
revokeApiKey,
} from "./broker";
import * as serviceManager from "./service-manager";
import { ensureBucket, meshBucketName, minioClient } from "./minio";
@@ -2463,6 +2466,69 @@ function handleConnection(ws: WebSocket): void {
break;
}
// ── API keys (v0.2.0) ───────────────────────────────────────
// TODO: gate to admin members only. For now any authed peer can
// issue keys for their mesh — matches existing `share` invite
// semantics; tighter ACL lands with the broader admin role work.
case "apikey_create": {
const ac = msg as Extract<WSClientMessage, { type: "apikey_create" }>;
if (!ac.label || !ac.capabilities?.length) {
sendError(ws, "invalid_args", "label and at least one capability required", _reqId);
break;
}
const result = await createApiKey({
meshId: conn.meshId,
label: ac.label,
capabilities: ac.capabilities,
topicScopes: ac.topicScopes ?? null,
issuedByMemberId: conn.memberId,
expiresAt: ac.expiresAt ? new Date(ac.expiresAt) : undefined,
});
const resp: WSServerMessage = {
type: "apikey_created",
id: result.id,
secret: result.secret,
label: result.label,
prefix: result.prefix,
capabilities: result.capabilities,
topicScopes: result.topicScopes,
createdAt: result.createdAt.toISOString(),
...(_reqId ? { _reqId } : {}),
};
conn.ws.send(JSON.stringify(resp));
log.info("ws apikey_create", { presence_id: presenceId, label: ac.label, key_id: result.id });
break;
}
case "apikey_list": {
const keys = await listApiKeys(conn.meshId);
const resp: WSServerMessage = {
type: "apikey_list_response",
keys: keys.map((k) => ({
id: k.id,
label: k.label,
prefix: k.prefix,
capabilities: k.capabilities,
topicScopes: k.topicScopes,
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
revokedAt: k.revokedAt?.toISOString() ?? null,
expiresAt: k.expiresAt?.toISOString() ?? null,
})),
...(_reqId ? { _reqId } : {}),
};
conn.ws.send(JSON.stringify(resp));
break;
}
case "apikey_revoke": {
const ar = msg as Extract<WSClientMessage, { type: "apikey_revoke" }>;
if (!ar.id) { sendError(ws, "invalid_args", "id required", _reqId); break; }
await revokeApiKey({ meshId: conn.meshId, id: ar.id });
log.info("ws apikey_revoke", { presence_id: presenceId, key_id: ar.id });
break;
}
case "set_state": {
const ss = msg as Extract<WSClientMessage, { type: "set_state" }>;
// Look up the display name for attribution.

View File

@@ -179,6 +179,60 @@ export interface WSLeaveGroupMessage {
name: string;
}
// ── API keys (v0.2.0) ───────────────────────────────────────────────
// Issuance/management of bearer tokens for REST + external WS. Only the
// mesh admin can issue; keys are scoped by capability + optional topic
// whitelist. Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
export interface WSApiKeyCreateMessage {
type: "apikey_create";
label: string;
capabilities: Array<"send" | "read" | "state_write" | "admin">;
topicScopes?: string[];
expiresAt?: string;
_reqId?: string;
}
export interface WSApiKeyListMessage {
type: "apikey_list";
_reqId?: string;
}
export interface WSApiKeyRevokeMessage {
type: "apikey_revoke";
id: string;
_reqId?: string;
}
export interface WSApiKeyCreatedMessage {
type: "apikey_created";
id: string;
/** Plaintext secret — shown ONCE, never returned again. */
secret: string;
label: string;
prefix: string;
capabilities: Array<"send" | "read" | "state_write" | "admin">;
topicScopes: string[] | null;
createdAt: string;
_reqId?: string;
}
export interface WSApiKeyListResponseMessage {
type: "apikey_list_response";
keys: Array<{
id: string;
label: string;
prefix: string;
capabilities: Array<"send" | "read" | "state_write" | "admin">;
topicScopes: string[] | null;
createdAt: string;
lastUsedAt: string | null;
revokedAt: string | null;
expiresAt: string | null;
}>;
_reqId?: string;
}
// ── Topics (v0.2.0) ─────────────────────────────────────────────────
// Topics complement groups: groups are identity tags, topics are
// conversation scopes. targetSpec for topic-tagged messages is
@@ -1253,6 +1307,9 @@ export type WSClientMessage =
| WSTopicMembersMessage
| WSTopicHistoryMessage
| WSTopicMarkReadMessage
| WSApiKeyCreateMessage
| WSApiKeyListMessage
| WSApiKeyRevokeMessage
| WSSetStateMessage
| WSGetStateMessage
| WSListStateMessage
@@ -1425,6 +1482,8 @@ export type WSServerMessage =
| WSTopicListResponseMessage
| WSTopicMembersResponseMessage
| WSTopicHistoryResponseMessage
| WSApiKeyCreatedMessage
| WSApiKeyListResponseMessage
| WSStateChangeMessage
| WSStateResultMessage
| WSStateListMessage