fa_hub_client library

DAP/1 hub client for flutter_agent_harness.

Signed WebSocket messaging (channels, DMs, presence) with end-to-end encrypted payloads — the hub only ever sees ciphertext. Shaped as an upstreamable PR for IstiN/flutter_agent_harness (lib/src/hub/ there).

Classes

AgentInfo
Peer directory entry returned by whois/presence.
AgentMessage
One message between two agents (upstream shape).
ChannelKeys
One channel's X25519 keypair (base64).
ChannelStore
Runtime channel-key state for one client: seeded from the shared channels file, mutated by auto-keygen and accepted invites, persisted read-modify-write. The hub never sees any of these keys (only public keys via join).
DapSettings
Optional persisted settings: ~/.dap/config.json (all fields optional).
FahPlugin
Base interface for a fah plugin / package extension.
HubClient
HubConfig
HubIdentity
HubMessagingRepository
HubPlugin
HubStatus
Connection snapshot for the dap_status tool: who we are, where we are connected, what we can send to, and how the handshake is going.
InboundMessage
One inbound msg frame: decrypted when a key is available.
JoinDenied
A channel join the hub denied (issue #1016). Master-gated hubs reject client-secret joins of unknown channels with access_denied (msg prefix "channel creation requires"); ACL rejections carry the same code with msg "pubkey not on channel ACL" — msg is the discriminator, the wire never echoes the channel id.
MailboxEntry
One entry in the messaging-fabric directory (upstream shape).
MessagingRepository
Isolated messaging backend for agent inboxes (upstream shape).
PendingInvite
A pending by-name invite in the machine-shared ~/.dap/config.json (invites: [{name, channel}]): armed by dap_invite <name> for a user not yet on the hub, removed once the chankey DM was delivered.
PendingInvites
Pending by-name invites — dap_invite <name> against a user not yet on the hub: persisted in the machine-shared ~/.dap/config.json under invites: [{name, channel}], delivered automatically once the name appears online. One presence query per interval (~15 s) plus an immediate check at arm time and after every welcome (restart redelivery). Mirrors the omp-extension pending-invite behavior.
PluginContext
Context passed to FahPlugin.register.
PluginIO
IO surface exposed to plugins for writing to the terminal.

Enums

DapSecretSource
Where resolveDapClientSecret found the dial credential.

Constants

defaultDapUrl → const String
envChannelsFile → const String
envClientSecret → const String
envConfigFile → const String
envMasterSecret → const String
unauthorizedMsg → const String
Frozen cross-adapter text for a hub bearer rejection (HTTP 401 before the websocket upgrade) — byte-identical in every DAP adapter; the hub answers unauthorized for a missing/unknown secret.

Functions

canonicalJson(Object? value) → String
Recursively key-sorted, whitespace-free JSON encoding of value.
dapHostOf(String url) → String
The hub address for paste-ready connect lines: scheme and trailing /ws stripped (ws://h:1/ws → h:1).
decryptPayload({required SimpleKeyPair recipientDhKeyPair, required SimplePublicKey senderDhPubkey, required String frameId, required String aadTarget, required String ciphertextB64}) → Future<String>
Decrypts a payload produced by encryptPayload. aadTarget must equal the sender's. Throws on wrong key or tampered ciphertext.
defaultDapConfigFile([String? home, Map<String, String>? environment]) → String
~/.dap/config.json path — the single authority every reader/writer of that file goes through (readDapConfig, persistDapConfig, resolveDapSettings). DAP_CONFIG_FILE (from environment, default Platform.environment) wins outright; home overrides ~ (test seam).
defaultDapKeyPath(String? name, String home) → String
Default identity file, derived from the agent name (or hostname): ~/.dap/keys/fah/<sanitized>.key.
defaultHome([Map<String, String> environment = const {}]) → String
~ on POSIX and Windows alike (dart:io has no homedir).
encryptPayload({required SimpleKeyPair senderDhKeyPair, required SimplePublicKey recipientDhPubkey, required String frameId, required String aadTarget, required String plaintext}) → Future<String>
Encrypts plaintext for the holder of the DH private key matching recipientDhPubkey. aadTarget is the channel name (channel send) or recipient agentId (DM).
hexEncode(List<int> bytes) → String
hkdfSha256({required List<int> ikm, required List<int> salt, required List<int> info, required int length}) → Future<Uint8List>
HKDF-SHA256 extract+expand per RFC 5869.
loadChannelKeys(String file) → Future<Map<String, ChannelKeys>>
Channels file → keypairs. Missing or invalid file = no channels; only entries with both keys survive.
newChannelKeypair() → Future<ChannelKeys>
Fresh channel X25519 keypair — generated by the channel's first user.
newFrameId() → String
Opaque unique frame id, uuid-v4 shaped.
normalizeDapHost(String host) → String
dap_connect host normalization: no scheme → ws://, no path → /ws (hub.example.com → ws://hub.example.com/ws, hub:8787 → ws://hub:8787/ws, an explicit ws(s)://…/path is kept as-is).
parseChankeyInvite(String text) → ({String channel, String priv, String pub})?
A DM whose decrypted text is exactly a channel-invite payload {"t":"chankey","channel","pub","priv"}. Regular chat that happens to start with { still fails the shape check and returns null.
persistChannelKeys(String file, String name, ChannelKeys keys) → Future<void>
Read-modify-write: keeps the other channels; creates the parent dir (e.g. ~/.dap) on demand.
persistDapConfig({String? url, String? name, List<String>? channels, List<PendingInvite>? invites, String? clientSecret, bool clearClientSecret = false, String? file}) → Future<void>
Read-modify-write of ~/.dap/config.json (dap_connect persistence): merges url/name/channels/clientSecret; clearClientSecret removes a stale cache entry (401 recovery — a secret the hub rejected must not win precedence over the master secret on later launches). The default-room list only grows (a union — rooms are never un-remembered). invites is the authoritative pending-invite list — an empty list removes them all (delivered entries are dropped by the caller). file is injectable for tests. Auto-join on later launches flows through the shared channels file (the store joins every channel it has keys for).
randomHex(int nChars) → String
Random lowercase hex string of nChars characters (nonce, ids).
readDapConfig(String file) → Map<String, dynamic>
Reads ~/.dap/config.json; a missing or invalid file counts as absent.
readPendingInvites(String file) → List<PendingInvite>
The invites list from ~/.dap/config.json; a missing or non-array key counts as empty (back-compat with files written before invites existed), malformed entries are skipped.
resolveDapClientSecret({Map<String, String> environment = const {}, Map<String, dynamic>? config}) → ({bool enroll, String? master, DapSecretSource source, String? token})
Hub dial credential per the enrollment contract: DAP_CLIENT_SECRET (env) > clientSecret from ~/.dap/config.json (config) > DAP_MASTER_SECRET (env — enroll-mode: the connection enrolls once and the hub-issued secret replaces it). token: null dials anyway — the hub answers 401 and the client surfaces the frozen enrollment hint.
resolveDapSettings({HubConfig config = const HubConfig(), Map<String, String> environment = const {}, String? home}) → DapSettings
Resolves the effective settings (see library doc for precedence). config is the already env-merged hub: section (HubConfig.fromMap).
signFrame(Map<String, dynamic> frame, SimpleKeyPair keyPair) → Future<String>
Signs frame (without sig field) and returns the b64 signature.
signingPayload(Map<String, dynamic> frame) → Future<String>
The DAP/1 signing payload for frame (which must not yet contain sig).
verifyFrame(Map<String, dynamic> frame, String sigB64, SimplePublicKey publicKey) → Future<bool>
Verifies a DAP/1 signature over frame (without sig field).

Typedefs

DapConnection = ({String agentId, List<String> channels, String? name, bool ok, String url})
dap_connect result: the connection now in force (the new welcome's agentId, the normalized url, the display name, every joinable room).
ExternalSteeringSource = Future<List<AgentMessage>> Function()
The Agent.externalSteeringSource seam: called at every turn boundary (before the first turn and after each one); drained BEFORE the in-process steering queue. Contract: must not throw; return an empty list when nothing arrived.
InviteResult = ({String channel, String? connectLine, String? error, bool ok, bool pending, String to})
dap_invite result: ok: false, error is an honest failure (not connected, ambiguous name, DM failure); otherwise the chankey DM went out immediately (pending: false) or the invite was armed for a user not yet online (pending: true — delivered automatically when they connect; connectLine is the paste-ready line for the invited user).
SlashCommand = Future<void> Function(List<String> args)
A slash-command handler registered by a plugin (upstream shape).

Exceptions / Errors

HubError
A hub error frame.