ruvnet/ruflo · error
only private channels have keys to grant
Error message
only private channels have keys to grant
What it means
The x-federation grant-key handler only operates on private channels: keys are distributed encrypted via NIP-44 to members, and public (pub:) channels have no key to grant. It throws when the supplied channel id does not start with 'prv:'. This is an operation-applicability check, not a data-corruption error.
Solutions
- Pass the private channel id exactly as returned when the channel was created (prv:<16 hex>)
- If the channel is public (pub: prefix), do not grant keys — public channels are open; post/join directly
- Look up the correct prv: id in the channel store before calling the tool
Example fix
// before
grant({ channel: 'general', pubkey: 'abc...' }); // throws
// after
grant({ channel: 'prv:1a2b3c4d5e6f7a8b', pubkey: 'abc...' }); Defensive patterns
Strategy: validation
Validate before calling
function isPrivateChannel(id: string): boolean { return String(id).startsWith('prv:'); }
if (!isPrivateChannel(channelId)) throw new Error('grant-key requires a prv: channel id'); Type guard
function isPrivateChannelId(x: unknown): x is `prv:${string}` {
return typeof x === 'string' && x.startsWith('prv:') && /^prv:[0-9a-f]{16}$/.test(x);
} Try / catch
try {
await grantKey({ channel, pubkey });
} catch (e) {
if (e.message === 'only private channels have keys to grant') {
// public channels need no key grant — join/post directly, or use the correct prv: id
return { skipped: true, reason: 'public channel' };
}
throw e;
} Prevention
- Store the prv:<16-hex> id returned at channel creation and reuse it verbatim
- Check the prefix (pub: vs prv:) before choosing grant vs post/join operations
- Never pass bare channel names to tool calls that expect channel ids
When it happens
Trigger: Calling the grant-key MCP tool with channel='pub:<name>', a bare channel name like 'general', or any id lacking the prv: prefix; mixing up a public channel id with a private one when scripting tool calls.
Common situations: Passing the friendly channel name instead of the prv:<16-hex> id returned at creation; attempting to add members to a public channel (just post to it instead); copying the wrong channel id from the store.
Understand the failure class
Background: UnsupportedOperationException and "is not supported" errors: when a library deliberately refuses a call — this error's family across 30 libraries.
Related errors
- actualUsd must be a non-negative finite number
- Agent config must include id, name, and type
- agent must be an object
- anchor task has no query
- anchor task requires non-empty string labels
AI-assisted analysis of ruvnet/ruflo@2602b642d9 (2026-09-15).
Data as JSON: /api/errors/29c7af3dd7407a0f.
Report an issue: GitHub.
Appendix: source
Thrown at v3/@claude-flow/cli/src/mcp-tools/x-federation-channels.ts:134
const { name, visibility } = input as { name: string; visibility: 'public' | 'private' };
if (visibility === 'public') return { channel: publicChannelId(name), visibility, note: 'Any relay member can read this channel.' };
const key = newChannelKey(); const channel = privateChannelId(key);
const store = readStore(); store[channel] = { key, name, at: new Date().toISOString() }; writeStore(store);
return { channel, visibility, name, keyStoredAt: STORE_FILE(),
note: 'The key never leaves this machine. Grant others with x_federation_channel_grant. There is no recovery if the key file is lost, and no revocation — removing someone means rotating to a new channel.' };
},
},
{
name: 'x_federation_channel_grant',
description: "Grant a member access to a private channel by sealing its key to their pubkey with NIP-44 (ECDH), published as a ChannelGrant event only they can open. Use when adding a participant to an existing private channel. Publishing the raw key into a channel or a chat is wrong: it is a bearer secret, and anyone who sees it can read every past and future message, because there is no revocation.",
inputSchema: { type: 'object', properties: {
channel: { type: 'string', description: 'Private channel id (prv:<16 hex>) you hold the key for.' },
pubkey: { type: 'string', description: "The member's 64-hex Nostr pubkey." },
relayWs: { type: 'string', description: 'Relay URL; takes precedence over RUFLO_X_RELAY_WS (default wss://relay.ruv.io).' },
}, required: ['channel', 'pubkey'] },
handler: async (input) => {
const i = input as { channel: string; pubkey: string; relayWs?: string };
if (!isPrivateChannel(i.channel)) throw new Error('only private channels have keys to grant');
if (!/^[0-9a-f]{64}$/i.test(i.pubkey)) throw new Error('pubkey must be 64 hex');
const t = await loadTools(); if (!t) return degraded();
const entry = readStore()[i.channel];
if (!entry) throw new Error(`no key held for ${i.channel} — create it or accept a grant first`);
const { sk, pubkey } = loadOrCreateKey(t.nt as never, KEY_FILE());
const conv = t.nip44.v2.utils.getConversationKey(sk, i.pubkey);
const sealed = t.nip44.v2.encrypt(entry.key, conv);
const relay = RELAY_WS(i.relayWs);
const eventId = await relayCall(relay, sk, t.nt, (ws) => publishEvent(ws, t.nt, sk,
[['t', 'ruflo-swarm'], ['k', 'ChannelGrant'], ['c', i.channel], ['p', i.pubkey]],
JSON.stringify({ type: 'ChannelGrant', channel: i.channel, sealed, ts: new Date().toISOString() })));
return { ok: true, channel: i.channel, grantedTo: i.pubkey, grantedBy: pubkey, eventId,
note: 'Only that pubkey can open the seal. Grants are not revocable — rotate the channel to remove someone.' };
},
},
{
name: 'x_federation_channel_accept',
description: 'Accept private-channel grants addressed to your key: finds ChannelGrant events tagged to your pubkey, opens each with your own secret key, and caches the channel keys locally. Use when someone tells you they granted you a channel. Asking them to send you the key directly is wrong because it exposes a bearer secret in a channel you do not control.',View on GitHub (pinned to 2602b642d9)