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.