ruvnet/ruflo · error

channel name must match [a-z0-9][a-z0-9._-]

Error message

channel name must match [a-z0-9][a-z0-9._-]{0,63}

What it means

publicChannelId validates a federation channel name against CHANNEL_NAME_RE (lowercase alphanumeric start, then [a-z0-9._-], max 64 chars) and throws when the name does not match. Channel ids become pub:<name>, so the regex keeps store keys, relay topics, and URLs safe and canonical. This is a strict input-validation guard, not an environmental failure.

Solutions

  1. Rename the channel to lowercase kebab-case: lowercase letters/digits start, then only [a-z0-9._-], max 64 chars
  2. Trim whitespace and strip any 'pub:'/'prv:' prefix before passing the name
  3. If the desired name has uppercase or symbols, slugify it (e.g. 'Team Updates' -> 'team-updates')

Example fix

// before
publicChannelId('Team Updates'); // throws
// after
publicChannelId('team-updates');
// or slugify first
const safe = name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[._-]+/, '').slice(0, 64);
Defensive patterns

Strategy: validation

Validate before calling

const CHANNEL_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
function isValidChannelName(n: string): boolean { return CHANNEL_NAME_RE.test(n.trim()); }

Try / catch

try {
  const id = publicChannelId(name);
} catch {
  const slug = name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[._-]+/, '').slice(0, 64);
  if (!CHANNEL_NAME_RE.test(slug)) throw new Error(`cannot slugify channel name: ${name}`);
  const id = publicChannelId(slug);
}

Prevention

When it happens

Trigger: Calling any x-federation channel tool (create/join public channel) with a name containing uppercase letters, spaces, slashes, or other symbols; empty name; name longer than 64 characters; name starting with '.', '_' or '-' (must start [a-z0-9]).

Common situations: Typing a human-readable channel name like 'Team Updates' or 'My_Channel' instead of kebab-case; passing a full channel id ('pub:general') instead of the bare name; auto-generated names with timestamps containing ':' or '/'; whitespace from copy-paste.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


AI-assisted analysis of ruvnet/ruflo@2602b642d9 (2026-09-15). Data as JSON: /api/errors/021de9f2b08aca25. Report an issue: GitHub.

Appendix: source

Thrown at v3/@claude-flow/cli/src/mcp-tools/x-federation-channels.ts:54

    const nt = (await import('nostr-tools/pure')) as unknown as Nt;
    const { nip44 } = (await import('nostr-tools')) as unknown as { nip44: Nip44 };
    return { nt, nip44 };
  } catch { return null; }
}
const degraded = () => ({ degraded: true, reason: 'nostr-tools not installed', hint: 'npm i nostr-tools  (secp256k1 + NIP-44 are not in node:crypto)' });

/** Locally cached channel keys, 0600. Losing this file loses the channels in it — by design. */
export type ChannelStore = Record<string, { key: string; name?: string; grantedBy?: string; at: string }>;
export function readStore(file = STORE_FILE()): ChannelStore {
  if (!existsSync(file)) return {};
  try { return JSON.parse(readFileSync(file, 'utf8')) as ChannelStore; } catch { return {}; }
}
export function writeStore(store: ChannelStore, file = STORE_FILE()): void {
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
  writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
}
export function publicChannelId(name: string): string {
  if (!CHANNEL_NAME_RE.test(String(name))) throw new Error('channel name must match [a-z0-9][a-z0-9._-]{0,63}');
  return `pub:${name}`;
}
export function privateChannelId(keyHex: string): string {
  const k = Buffer.from(keyHex, 'hex');
  if (k.length !== 32) throw new Error('channel key must be 32 bytes (64 hex)');
  return `prv:${createHash('sha256').update(k).digest('hex').slice(0, 16)}`;
}
export function newChannelKey(): string { return randomBytes(32).toString('hex'); }
export function isPrivateChannel(id: string): boolean { return String(id).startsWith('prv:'); }

async function relayCall<T>(relayWs: string, sk: Uint8Array, nt: Nt, fn: (ws: WsLike) => Promise<T>): Promise<T> {
  const { default: WebSocket } = await import('ws');
  const ws = new WebSocket(relayWs, { perMessageDeflate: false }) as unknown as WsLike;
  await new Promise<void>((resolve, reject) => {
    const t = setTimeout(() => reject(new Error('relay auth timeout')), 15000);
    ws.on('message', (d: Buffer) => {
      const m = JSON.parse(d.toString());
      if (m[0] === 'AUTH' && typeof m[1] === 'string') {

View on GitHub (pinned to 2602b642d9)