feat(broker+cli): apikey create/list/revoke verbs (v0.2.0 #71)
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:
@@ -93,6 +93,9 @@ import {
|
|||||||
topicHistory,
|
topicHistory,
|
||||||
markTopicRead,
|
markTopicRead,
|
||||||
appendTopicMessage,
|
appendTopicMessage,
|
||||||
|
createApiKey,
|
||||||
|
listApiKeys,
|
||||||
|
revokeApiKey,
|
||||||
} from "./broker";
|
} from "./broker";
|
||||||
import * as serviceManager from "./service-manager";
|
import * as serviceManager from "./service-manager";
|
||||||
import { ensureBucket, meshBucketName, minioClient } from "./minio";
|
import { ensureBucket, meshBucketName, minioClient } from "./minio";
|
||||||
@@ -2463,6 +2466,69 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
break;
|
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": {
|
case "set_state": {
|
||||||
const ss = msg as Extract<WSClientMessage, { type: "set_state" }>;
|
const ss = msg as Extract<WSClientMessage, { type: "set_state" }>;
|
||||||
// Look up the display name for attribution.
|
// Look up the display name for attribution.
|
||||||
|
|||||||
@@ -179,6 +179,60 @@ export interface WSLeaveGroupMessage {
|
|||||||
name: string;
|
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 (v0.2.0) ─────────────────────────────────────────────────
|
||||||
// Topics complement groups: groups are identity tags, topics are
|
// Topics complement groups: groups are identity tags, topics are
|
||||||
// conversation scopes. targetSpec for topic-tagged messages is
|
// conversation scopes. targetSpec for topic-tagged messages is
|
||||||
@@ -1253,6 +1307,9 @@ export type WSClientMessage =
|
|||||||
| WSTopicMembersMessage
|
| WSTopicMembersMessage
|
||||||
| WSTopicHistoryMessage
|
| WSTopicHistoryMessage
|
||||||
| WSTopicMarkReadMessage
|
| WSTopicMarkReadMessage
|
||||||
|
| WSApiKeyCreateMessage
|
||||||
|
| WSApiKeyListMessage
|
||||||
|
| WSApiKeyRevokeMessage
|
||||||
| WSSetStateMessage
|
| WSSetStateMessage
|
||||||
| WSGetStateMessage
|
| WSGetStateMessage
|
||||||
| WSListStateMessage
|
| WSListStateMessage
|
||||||
@@ -1425,6 +1482,8 @@ export type WSServerMessage =
|
|||||||
| WSTopicListResponseMessage
|
| WSTopicListResponseMessage
|
||||||
| WSTopicMembersResponseMessage
|
| WSTopicMembersResponseMessage
|
||||||
| WSTopicHistoryResponseMessage
|
| WSTopicHistoryResponseMessage
|
||||||
|
| WSApiKeyCreatedMessage
|
||||||
|
| WSApiKeyListResponseMessage
|
||||||
| WSStateChangeMessage
|
| WSStateChangeMessage
|
||||||
| WSStateResultMessage
|
| WSStateResultMessage
|
||||||
| WSStateListMessage
|
| WSStateListMessage
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ export function classifyInvocation(command: string, positionals: string[]): Invo
|
|||||||
const writeVerbs = new Set(["create", "join", "leave"]);
|
const writeVerbs = new Set(["create", "join", "leave"]);
|
||||||
return { resource: "topic", verb, isWrite: writeVerbs.has(verb) };
|
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.
|
// Platform — sub is the verb.
|
||||||
case "vector": case "graph": case "context": case "stream":
|
case "vector": case "graph": case "context": case "stream":
|
||||||
|
|||||||
120
apps/cli/src/commands/apikey.ts
Normal file
120
apps/cli/src/commands/apikey.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh apikey <verb>` — 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<Capability>(["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<number> {
|
||||||
|
if (!label) {
|
||||||
|
render.err("Usage: claudemesh apikey create <label> [--cap send,read] [--topic deploys]");
|
||||||
|
return EXIT.INVALID_ARGS;
|
||||||
|
}
|
||||||
|
const caps = parseCapabilities(flags.cap);
|
||||||
|
if (caps.length === 0) {
|
||||||
|
render.err("at least one capability required: --cap send,read,state_write,admin");
|
||||||
|
return EXIT.INVALID_ARGS;
|
||||||
|
}
|
||||||
|
const topicScopes = flags.topic
|
||||||
|
? flags.topic.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
const result = await client.apiKeyCreate({
|
||||||
|
label,
|
||||||
|
capabilities: caps,
|
||||||
|
topicScopes,
|
||||||
|
expiresAt: flags.expires,
|
||||||
|
});
|
||||||
|
if (!result) {
|
||||||
|
render.err("apikey create failed");
|
||||||
|
return EXIT.INTERNAL_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
render.ok("created", `${bold(result.label)} ${dim(result.id.slice(0, 8))}`);
|
||||||
|
process.stdout.write(`\n ${yellow("⚠ secret shown once — copy it now:")}\n\n`);
|
||||||
|
process.stdout.write(` ${green(result.secret)}\n\n`);
|
||||||
|
process.stdout.write(` ${dim(`capabilities: ${result.capabilities.join(", ")}`)}\n`);
|
||||||
|
if (result.topicScopes?.length) {
|
||||||
|
process.stdout.write(` ${dim(`topics: ${result.topicScopes.map((t) => "#" + t).join(", ")}`)}\n`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(` ${dim("topics: all (no scope)")}\n`);
|
||||||
|
}
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runApiKeyList(flags: ApiKeyFlags): Promise<number> {
|
||||||
|
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
const keys = await client.apiKeyList();
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(keys, null, 2));
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
}
|
||||||
|
if (keys.length === 0) {
|
||||||
|
render.info(dim("no api keys in this mesh."));
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
}
|
||||||
|
render.section(`api keys (${keys.length})`);
|
||||||
|
for (const k of keys) {
|
||||||
|
const status = k.revokedAt
|
||||||
|
? red("revoked")
|
||||||
|
: k.expiresAt && new Date(k.expiresAt) < new Date()
|
||||||
|
? yellow("expired")
|
||||||
|
: green("active");
|
||||||
|
const lastUsed = k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : "never";
|
||||||
|
const scope = k.topicScopes?.length ? k.topicScopes.map((t) => "#" + t).join(",") : "all topics";
|
||||||
|
process.stdout.write(` ${bold(k.label)} ${status} ${dim(k.id.slice(0, 8))}\n`);
|
||||||
|
process.stdout.write(` ${dim(`${k.prefix}… caps: ${k.capabilities.join(",")} scope: ${scope} last_used: ${lastUsed}`)}\n`);
|
||||||
|
}
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runApiKeyRevoke(id: string, flags: ApiKeyFlags): Promise<number> {
|
||||||
|
if (!id) {
|
||||||
|
render.err("Usage: claudemesh apikey revoke <id>");
|
||||||
|
return EXIT.INVALID_ARGS;
|
||||||
|
}
|
||||||
|
return await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||||
|
await client.apiKeyRevoke(id);
|
||||||
|
if (flags.json) console.log(JSON.stringify({ revoked: id }));
|
||||||
|
else render.ok("revoked", clay(id.slice(0, 8)));
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -102,6 +102,11 @@ Profile / presence (resource form)
|
|||||||
claudemesh group join @<name> join a group (--role X)
|
claudemesh group join @<name> join a group (--role X)
|
||||||
claudemesh group leave @<name> leave a group
|
claudemesh group leave @<name> leave a group
|
||||||
|
|
||||||
|
API keys (REST + external WS auth, v0.2.0)
|
||||||
|
claudemesh apikey create <label> issue [--cap send,read] [--topic deploys]
|
||||||
|
claudemesh apikey list show keys (status, last-used, scope)
|
||||||
|
claudemesh apikey revoke <id> revoke a key
|
||||||
|
|
||||||
Topic (conversation scope, v0.2.0)
|
Topic (conversation scope, v0.2.0)
|
||||||
claudemesh topic create <name> create a topic [--description --visibility]
|
claudemesh topic create <name> create a topic [--description --visibility]
|
||||||
claudemesh topic list list topics in the mesh
|
claudemesh topic list list topics in the mesh
|
||||||
@@ -509,6 +514,24 @@ async function main(): Promise<void> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apikey — REST + external WS bearer tokens (v0.2.0)
|
||||||
|
case "apikey": case "api-key": {
|
||||||
|
const sub = positionals[0];
|
||||||
|
const f = {
|
||||||
|
mesh: flags.mesh as string,
|
||||||
|
json: !!flags.json,
|
||||||
|
cap: flags.cap as string,
|
||||||
|
topic: flags.topic as string,
|
||||||
|
expires: flags.expires as string,
|
||||||
|
};
|
||||||
|
const arg = positionals[1] ?? "";
|
||||||
|
if (sub === "create") { const { runApiKeyCreate } = await import("~/commands/apikey.js"); process.exit(await runApiKeyCreate(arg, f)); }
|
||||||
|
else if (sub === "list") { const { runApiKeyList } = await import("~/commands/apikey.js"); process.exit(await runApiKeyList(f)); }
|
||||||
|
else if (sub === "revoke") { const { runApiKeyRevoke } = await import("~/commands/apikey.js"); process.exit(await runApiKeyRevoke(arg, f)); }
|
||||||
|
else { console.error("Usage: claudemesh apikey <create|list|revoke>"); process.exit(EXIT.INVALID_ARGS); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// topic — conversational primitive within a mesh (v0.2.0)
|
// topic — conversational primitive within a mesh (v0.2.0)
|
||||||
case "topic": {
|
case "topic": {
|
||||||
const sub = positionals[0];
|
const sub = positionals[0];
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ export class BrokerClient {
|
|||||||
private topicListResolvers = new Map<string, { resolve: (topics: Array<{ id: string; name: string; description: string | null; visibility: "public" | "private" | "dm"; memberCount: number; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
|
private topicListResolvers = new Map<string, { resolve: (topics: Array<{ id: string; name: string; description: string | null; visibility: "public" | "private" | "dm"; memberCount: number; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||||
private topicMembersResolvers = new Map<string, { resolve: (members: Array<{ memberId: string; pubkey: string; displayName: string; role: "lead" | "member" | "observer"; joinedAt: string; lastReadAt: string | null }>) => void; timer: NodeJS.Timeout }>();
|
private topicMembersResolvers = new Map<string, { resolve: (members: Array<{ memberId: string; pubkey: string; displayName: string; role: "lead" | "member" | "observer"; joinedAt: string; lastReadAt: string | null }>) => void; timer: NodeJS.Timeout }>();
|
||||||
private topicHistoryResolvers = new Map<string, { resolve: (messages: Array<{ id: string; senderPubkey: string; nonce: string; ciphertext: string; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
|
private topicHistoryResolvers = new Map<string, { resolve: (messages: Array<{ id: string; senderPubkey: string; nonce: string; ciphertext: string; createdAt: string }>) => void; timer: NodeJS.Timeout }>();
|
||||||
|
// ── API keys (v0.2.0) ──
|
||||||
|
private apiKeyCreatedResolvers = new Map<string, { resolve: (r: { id: string; secret: string; label: string; prefix: string; capabilities: Array<"send" | "read" | "state_write" | "admin">; topicScopes: string[] | null; createdAt: string } | null) => void; timer: NodeJS.Timeout }>();
|
||||||
|
private apiKeyListResolvers = new Map<string, { resolve: (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 }>) => void; timer: NodeJS.Timeout }>();
|
||||||
/** Directories from which this peer serves files. Default: [process.cwd()]. */
|
/** Directories from which this peer serves files. Default: [process.cwd()]. */
|
||||||
private sharedDirs: string[] = [process.cwd()];
|
private sharedDirs: string[] = [process.cwd()];
|
||||||
private _serviceCatalog: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string }> = [];
|
private _serviceCatalog: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string }> = [];
|
||||||
@@ -647,6 +650,60 @@ export class BrokerClient {
|
|||||||
this.ws.send(JSON.stringify({ type: "topic_mark_read", topic }));
|
this.ws.send(JSON.stringify({ type: "topic_mark_read", topic }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- API keys (v0.2.0) ---
|
||||||
|
|
||||||
|
async apiKeyCreate(args: {
|
||||||
|
label: string;
|
||||||
|
capabilities: Array<"send" | "read" | "state_write" | "admin">;
|
||||||
|
topicScopes?: string[];
|
||||||
|
expiresAt?: string;
|
||||||
|
}): Promise<{ id: string; secret: string; label: string; prefix: string; capabilities: Array<"send" | "read" | "state_write" | "admin">; topicScopes: string[] | null; createdAt: string } | null> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.apiKeyCreatedResolvers.set(reqId, {
|
||||||
|
resolve,
|
||||||
|
timer: setTimeout(() => {
|
||||||
|
if (this.apiKeyCreatedResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000),
|
||||||
|
});
|
||||||
|
this.ws!.send(
|
||||||
|
JSON.stringify({ type: "apikey_create", _reqId: reqId, ...args }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiKeyList(): Promise<
|
||||||
|
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;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.apiKeyListResolvers.set(reqId, {
|
||||||
|
resolve,
|
||||||
|
timer: setTimeout(() => {
|
||||||
|
if (this.apiKeyListResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000),
|
||||||
|
});
|
||||||
|
this.ws!.send(JSON.stringify({ type: "apikey_list", _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiKeyRevoke(id: string): Promise<void> {
|
||||||
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||||
|
this.ws.send(JSON.stringify({ type: "apikey_revoke", id }));
|
||||||
|
}
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
|
||||||
/** Set a shared state value visible to all peers in the mesh. */
|
/** Set a shared state value visible to all peers in the mesh. */
|
||||||
@@ -1836,6 +1893,22 @@ export class BrokerClient {
|
|||||||
this.resolveFromMap(this.topicHistoryResolvers, msgReqId, (msg.messages as any[]) ?? []);
|
this.resolveFromMap(this.topicHistoryResolvers, msgReqId, (msg.messages as any[]) ?? []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.type === "apikey_created") {
|
||||||
|
this.resolveFromMap(this.apiKeyCreatedResolvers, msgReqId, {
|
||||||
|
id: String(msg.id ?? ""),
|
||||||
|
secret: String(msg.secret ?? ""),
|
||||||
|
label: String(msg.label ?? ""),
|
||||||
|
prefix: String(msg.prefix ?? ""),
|
||||||
|
capabilities: (msg.capabilities as any[]) ?? [],
|
||||||
|
topicScopes: (msg.topicScopes as string[] | null) ?? null,
|
||||||
|
createdAt: String(msg.createdAt ?? ""),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "apikey_list_response") {
|
||||||
|
this.resolveFromMap(this.apiKeyListResolvers, msgReqId, (msg.keys as any[]) ?? []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === "push") {
|
if (msg.type === "push") {
|
||||||
this._statsCounters.messagesIn++;
|
this._statsCounters.messagesIn++;
|
||||||
const nonce = String(msg.nonce ?? "");
|
const nonce = String(msg.nonce ?? "");
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export const DEFAULT_POLICY: Policy = {
|
|||||||
{ resource: "sql", verb: "execute", decision: "prompt", reason: "raw SQL write to mesh DB" },
|
{ resource: "sql", verb: "execute", decision: "prompt", reason: "raw SQL write to mesh DB" },
|
||||||
{ resource: "graph", verb: "execute", decision: "prompt", reason: "graph mutation" },
|
{ resource: "graph", verb: "execute", decision: "prompt", reason: "graph mutation" },
|
||||||
{ resource: "mesh", verb: "delete", decision: "prompt", reason: "deletes the mesh for everyone" },
|
{ resource: "mesh", verb: "delete", decision: "prompt", reason: "deletes the mesh for everyone" },
|
||||||
|
{ resource: "apikey", verb: "create", decision: "prompt", reason: "issues a long-lived credential" },
|
||||||
|
{ resource: "apikey", verb: "revoke", decision: "prompt", reason: "irreversibly disables a credential" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user