diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index dec7006..8472171 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -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; + 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; + 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; // Look up the display name for attribution. diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts index e47ddf6..82147a8 100644 --- a/apps/broker/src/types.ts +++ b/apps/broker/src/types.ts @@ -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 diff --git a/apps/cli/src/cli/policy-classify.ts b/apps/cli/src/cli/policy-classify.ts index 13501f9..90e6586 100644 --- a/apps/cli/src/cli/policy-classify.ts +++ b/apps/cli/src/cli/policy-classify.ts @@ -92,6 +92,13 @@ export function classifyInvocation(command: string, positionals: string[]): Invo const writeVerbs = new Set(["create", "join", "leave"]); return { resource: "topic", verb, isWrite: writeVerbs.has(verb) }; } + case "apikey": case "api-key": { + // apikey verbs: create | list | revoke. create issues a credential — + // strongly destructive in security terms; revoke is also a write. + const verb = sub || "list"; + const writeVerbs = new Set(["create", "revoke"]); + return { resource: "apikey", verb, isWrite: writeVerbs.has(verb) }; + } // Platform — sub is the verb. case "vector": case "graph": case "context": case "stream": diff --git a/apps/cli/src/commands/apikey.ts b/apps/cli/src/commands/apikey.ts new file mode 100644 index 0000000..e2ab0a5 --- /dev/null +++ b/apps/cli/src/commands/apikey.ts @@ -0,0 +1,120 @@ +/** + * `claudemesh apikey ` — manage REST + external WS bearer tokens. + * + * The plaintext secret is shown ONCE on creation and never returned + * again — there's no recovery, only revoke + re-issue. Capabilities + * (send/read/state_write/admin) and topic scopes constrain what the key + * can do; a CI bot key with `--cap send,read --topic deploys` can only + * post and read on `#deploys`, never the whole mesh. + * + * Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md + */ + +import { withMesh } from "./connect.js"; +import { render } from "~/ui/render.js"; +import { bold, clay, dim, green, red, yellow } from "~/ui/styles.js"; +import { EXIT } from "~/constants/exit-codes.js"; + +type Capability = "send" | "read" | "state_write" | "admin"; + +export interface ApiKeyFlags { + mesh?: string; + json?: boolean; + /** Comma-separated capabilities: send,read,state_write,admin */ + cap?: string; + /** Comma-separated topic names (without #) — empty = all topics */ + topic?: string; + /** ISO 8601 expiry timestamp */ + expires?: string; +} + +function parseCapabilities(raw?: string): Capability[] { + if (!raw) return ["send", "read"]; // sensible default + const parts = raw.split(",").map((s) => s.trim()).filter(Boolean); + const valid = new Set(["send", "read", "state_write", "admin"]); + return parts.filter((p): p is Capability => valid.has(p as Capability)); +} + +export async function runApiKeyCreate(label: string, flags: ApiKeyFlags): Promise { + if (!label) { + render.err("Usage: claudemesh apikey create