Hanzo ZT

TypeScript SDK

Complete documentation for @hanzo/zt, the TypeScript zero-trust networking SDK with ZAP transport, async auth, and billing integration.

TypeScript SDK

The TypeScript SDK (@hanzo/zt) provides full type safety and async-first bindings for Hanzo ZT. It uses EventEmitter-based lifecycle management, 4-byte length-prefix framing for ZAP transport, and integrates with Hanzo IAM for authentication and Hanzo Commerce for billing.

Repository: github.com/hanzozt/zt-sdk-nodejs

Installation

npm install @hanzo/zt

Or with other package managers:

pnpm add @hanzo/zt
yarn add @hanzo/zt

Requirements

  • Node.js 18 or later (for native fetch and Buffer support)
  • TypeScript 5.0 or later (for full type inference)

Module Structure

The SDK is organized into three modules:

ModuleFilePurpose
authsrc/auth/hanzo.tsHanzo IAM authentication (JWT resolution, token refresh)
zapsrc/zap/transport.tsZAP transport with 4-byte BE length-prefix framing over ZT connections
billingsrc/billing/guard.tsBalance checks and usage recording via Hanzo Commerce API
// Import from the top-level package
import { HanzoAuth, ZapTransport, BillingGuard } from '@hanzo/zt';

// Or import specific modules
import { HanzoAuth } from '@hanzo/zt/auth';
import { ZapTransport } from '@hanzo/zt/zap';
import { BillingGuard } from '@hanzo/zt/billing';

Quick Start

import { HanzoAuth, ZapTransport, BillingGuard } from '@hanzo/zt';

async function main() {
  // 1. Resolve credentials from env or auth file
  const auth = await HanzoAuth.resolve();
  console.log(`Authenticated: ${auth.display()}`);

  // 2. Authenticate with ZT controller
  const transport = new ZapTransport({
    controllerUrl: 'https://zt-api.hanzo.ai',
    service: 'echo-service',
    token: await auth.getToken(),
  });

  await transport.connect();

  // 3. Send and receive ZAP frames
  transport.sendFrame(Buffer.from('Hello, ZT!'));
  const response = await transport.recvFrame();
  console.log(`Response: ${response.toString()}`);

  // 4. Clean up
  await transport.close();
}

main().catch(console.error);

Authentication

HanzoAuth

The HanzoAuth class resolves and manages JWT credentials from Hanzo IAM. It supports multiple token sources with a defined resolution order.

import { HanzoAuth } from '@hanzo/zt';

// Resolve from environment or auth file (recommended)
const auth = await HanzoAuth.resolve();

// From an explicit token string
const auth = HanzoAuth.fromToken('eyJhbGciOiJSUzI1NiIs...');

// From an explicit API key
const auth = HanzoAuth.fromApiKey('hk_live_xxxxx');

Token Resolution Order

When you call HanzoAuth.resolve(), the SDK checks these sources in order:

PrioritySourceDescription
1HANZO_API_KEY env varAPI key from environment
2HANZO_JWT env varExplicit JWT from environment
3~/.hanzo/auth.jsonCached token from hanzo login CLI
4~/.hanzo/credentials.jsonLegacy credential file

If no token is found, resolve() throws an AuthError with a descriptive message explaining where it looked.

HanzoAuth Methods

MethodReturnsDescription
HanzoAuth.resolve()Promise(HanzoAuth)Static. Resolve credentials from env or filesystem
HanzoAuth.fromToken(jwt)HanzoAuthStatic. Create from an explicit JWT string
HanzoAuth.fromApiKey(key)HanzoAuthStatic. Create from an API key
getToken()Promise(string)Returns the current JWT, refreshing if expired
getAuthHeader()Promise(Record(string, string))Returns { Authorization: 'Bearer ...' }
display()stringHuman-readable display with masked secrets
isExpired()booleanCheck if the current token has expired

Token Refresh

The getToken() method automatically refreshes expired tokens when an auth file is available:

const auth = await HanzoAuth.resolve();

// Token is refreshed automatically when expired
const token = await auth.getToken();

// Or check expiry explicitly
if (auth.isExpired()) {
  console.log('Token will be refreshed on next getToken() call');
}

// getAuthHeader() also triggers refresh
const headers = await auth.getAuthHeader();
// { Authorization: 'Bearer eyJhbGciOiJSUzI1NiIs...' }

Display

The display() method shows the credential type with masked secrets:

const auth = HanzoAuth.fromApiKey('hk_live_abc123xyz');
console.log(auth.display());
// "Hanzo API key (...3xyz)"

const auth = HanzoAuth.fromToken('eyJhbGciOiJSUzI1NiIs...');
console.log(auth.display());
// "Hanzo IAM ([email protected])"

Auth Flow

The full authentication flow with the ZT controller:

import { HanzoAuth } from '@hanzo/zt';

// 1. Resolve Hanzo IAM JWT
const auth = await HanzoAuth.resolve();
const token = await auth.getToken();

// 2. Present JWT to ZT controller via ext-jwt auth
const response = await fetch('https://zt-api.hanzo.ai/authenticate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    ...await auth.getAuthHeader(),
  },
  body: JSON.stringify({
    method: 'ext-jwt',
    envInfo: { os: process.platform, arch: process.arch },
  }),
});

const session = await response.json();
// session.token - ZT session token
// session.identity - assigned identity
// session.expiresAt - session expiry

In practice, ZapTransport.connect() handles this flow automatically.

ZAP Transport

ZapTransport

The ZapTransport class wraps a ZT connection with ZAP framing: each message is prefixed with a 4-byte big-endian length header followed by a Cap'n Proto payload.

+-------------------+-----------------------------+
| Length (4 bytes BE)| Payload (Cap'n Proto bytes) |
+-------------------+-----------------------------+

Constructor

import { ZapTransport, ZtConfig } from '@hanzo/zt';

const transport = new ZapTransport({
  controllerUrl: 'https://zt-api.hanzo.ai',
  service: 'my-service',
  token: 'eyJhbGciOiJSUzI1NiIs...',
  billingEnabled: true,
  commerceUrl: 'https://api.hanzo.ai/commerce',
  connectTimeout: 30_000,   // ms
  requestTimeout: 15_000,   // ms
});

ZapTransport Methods

MethodReturnsDescription
connect()Promise(void)Authenticate and establish the ZT connection
sendFrame(payload)voidSend a length-prefixed ZAP frame
recvFrame()Promise(Buffer)Receive and decode the next ZAP frame
close()Promise(void)Close the connection and emit close event
isConnected()booleanCheck if the transport is currently connected
localAddr()stringReturns the local ZT address (e.g. zt://local/my-service)
peerAddr()stringReturns the peer ZT address (e.g. zt://my-service)
sessionId()stringReturns the current session ID

Sending Frames

sendFrame writes the 4-byte big-endian length prefix followed by the payload:

const transport = new ZapTransport({ /* config */ });
await transport.connect();

// Send a text message
transport.sendFrame(Buffer.from('Hello, ZT!'));

// Send binary data
const payload = Buffer.alloc(8);
payload.writeDoubleBE(3.14159, 0);
transport.sendFrame(payload);

// Send JSON
const json = JSON.stringify({ action: 'list_tools' });
transport.sendFrame(Buffer.from(json, 'utf-8'));

Receiving Frames

recvFrame reads the 4-byte length prefix, then reads exactly that many bytes:

// Receive a single frame
const frame = await transport.recvFrame();
console.log(`Received ${frame.length} bytes`);

// Parse as text
const text = frame.toString('utf-8');

// Parse as JSON
const data = JSON.parse(frame.toString('utf-8'));

// Receive in a loop
while (transport.isConnected()) {
  try {
    const frame = await transport.recvFrame();
    handleFrame(frame);
  } catch (err) {
    if (err instanceof ConnectionClosedError) break;
    throw err;
  }
}

Event Handling

ZapTransport extends EventEmitter and emits lifecycle events:

import { ZapTransport } from '@hanzo/zt';

const transport = new ZapTransport({ /* config */ });

transport.on('connecting', () => {
  console.log('Connecting to ZT controller...');
});

transport.on('authenticated', (info) => {
  console.log(`Authenticated as ${info.identityId}`);
});

transport.on('session', (session) => {
  console.log(`Session ${session.id} created for ${session.service}`);
});

transport.on('connected', () => {
  console.log('ZAP transport ready');
});

transport.on('frame', (frame: Buffer) => {
  console.log(`Received frame: ${frame.length} bytes`);
});

transport.on('error', (err: Error) => {
  console.error(`Transport error: ${err.message}`);
});

transport.on('close', () => {
  console.log('Transport closed');
});

await transport.connect();

Events reference:

EventPayloadWhen
connectingnoneConnection attempt started
authenticated{ identityId: string }ext-jwt auth succeeded with controller
session{ id: string, service: string }API session created
billing{ service: string, ok: boolean }Billing check completed
connectednoneTransport is ready for frames
frameBufferA frame was received (passive listener)
errorErrorAn error occurred
closenoneTransport was closed

Billing

BillingGuard

The BillingGuard class integrates with Hanzo Commerce to enforce paid-only access and record usage.

import { BillingGuard } from '@hanzo/zt';

const guard = new BillingGuard({
  commerceUrl: 'https://api.hanzo.ai/commerce',
  token: await auth.getToken(),
});

Balance Check

checkBalance queries the Commerce API before dialing a service. If the account balance is zero or negative, it throws InsufficientBalanceError:

try {
  await guard.checkBalance('my-service');
  console.log('Balance OK, proceeding to dial');
} catch (err) {
  if (err instanceof InsufficientBalanceError) {
    console.error(`Add credits: ${err.message}`);
    console.error(`Current balance: ${err.balance}`);
    console.error(`Required minimum: ${err.minimum}`);
  }
}

Usage Recording

After a session ends, record bytes transferred and duration:

await guard.recordUsage({
  service: 'my-service',
  sessionId: transport.sessionId(),
  bytesSent: 4096,
  bytesReceived: 8192,
  durationMs: 5000,
});

BillingGuard Methods

MethodReturnsDescription
checkBalance(service)Promise(void)Check balance, throws InsufficientBalanceError if insufficient
recordUsage(record)Promise(void)Record session usage to Commerce API
getBalance()Promise(BalanceInfo)Retrieve current balance info without enforcement

UsageRecord Interface

interface UsageRecord {
  service: string;
  sessionId: string;
  bytesSent: number;
  bytesReceived: number;
  durationMs: number;
  metadata?: Record<string, string>;
}

BalanceInfo Interface

interface BalanceInfo {
  balance: number;
  currency: string;
  minimum: number;
  service: string;
}

Configuration

ZtConfig Interface

The full configuration interface:

interface ZtConfig {
  /** ZT controller URL (required) */
  controllerUrl: string;

  /** Service name to connect to (required) */
  service: string;

  /** JWT token for ext-jwt authentication (required) */
  token: string;

  /** Enable billing checks before dial. Default: true */
  billingEnabled?: boolean;

  /** Hanzo Commerce API URL. Default: 'https://api.hanzo.ai/commerce' */
  commerceUrl?: string;

  /** Connection timeout in milliseconds. Default: 30000 */
  connectTimeout?: number;

  /** Request timeout in milliseconds. Default: 15000 */
  requestTimeout?: number;

  /** Path to ZT identity file for cert-based auth */
  identityFile?: string;

  /** Custom metadata sent during authentication */
  envInfo?: Record(string, string);
}

Config field reference:

FieldTypeDefaultDescription
controllerUrlstringRequiredZT controller URL
servicestringRequiredService name to dial
tokenstringRequiredJWT for ext-jwt auth
billingEnabledbooleantrueEnable billing checks
commerceUrlstringhttps://api.hanzo.ai/commerceCommerce API URL
connectTimeoutnumber30000Connection timeout (ms)
requestTimeoutnumber15000Request timeout (ms)
identityFilestringundefinedPath to identity file
envInfoRecordundefinedCustom metadata for auth

Environment Variables

The SDK reads these environment variables:

VariableUsed byDescription
HANZO_API_KEYHanzoAuthAPI key (highest priority)
HANZO_JWTHanzoAuthExplicit JWT token
ZT_CONTROLLER_URLZapTransportOverride default controller URL
ZT_BILLING_ENABLEDBillingGuardSet to false to disable billing
HANZO_COMMERCE_URLBillingGuardOverride Commerce API URL

Error Handling

The SDK defines a hierarchy of typed errors for precise error handling:

import {
  ZtError,
  AuthError,
  ConnectionError,
  ConnectionClosedError,
  ServiceNotFoundError,
  InsufficientBalanceError,
  BillingError,
  ConfigError,
  TimeoutError,
} from '@hanzo/zt';

Error Classes

ClassExtendsDescription
ZtErrorErrorBase class for all ZT errors
AuthErrorZtErrorAuthentication failed (invalid token, expired, rejected)
ConnectionErrorZtErrorFailed to establish connection
ConnectionClosedErrorZtErrorConnection was closed unexpectedly
ServiceNotFoundErrorZtErrorNamed service does not exist
InsufficientBalanceErrorZtErrorBilling balance is zero or negative
BillingErrorZtErrorError contacting the Commerce API
ConfigErrorZtErrorConfiguration validation failed
TimeoutErrorZtErrorOperation exceeded timeout

Pattern: instanceof Checks

try {
  await transport.connect();
} catch (err) {
  if (err instanceof AuthError) {
    console.error(`Auth failed: ${err.message}`);
    // err.reason - detailed reason string
    // err.statusCode - HTTP status if applicable
  } else if (err instanceof ServiceNotFoundError) {
    console.error(`Service '${err.serviceName}' not found`);
  } else if (err instanceof InsufficientBalanceError) {
    console.error(`Balance: ${err.balance}, minimum: ${err.minimum}`);
  } else if (err instanceof TimeoutError) {
    console.error(`Timed out after ${err.timeoutMs}ms`);
  } else if (err instanceof ZtError) {
    console.error(`ZT error: ${err.message}`);
  } else {
    throw err; // re-throw unknown errors
  }
}

Error Properties

Each error class carries structured data beyond the message:

// AuthError
interface AuthError {
  message: string;
  reason: string;         // 'expired' | 'invalid' | 'rejected'
  statusCode?: number;    // HTTP status from controller
}

// ServiceNotFoundError
interface ServiceNotFoundError {
  message: string;
  serviceName: string;    // the service that was not found
}

// InsufficientBalanceError
interface InsufficientBalanceError {
  message: string;
  balance: number;        // current balance
  minimum: number;        // required minimum
  service: string;        // service that was being dialed
}

// TimeoutError
interface TimeoutError {
  message: string;
  timeoutMs: number;      // timeout that was exceeded
  operation: string;      // 'connect' | 'request' | 'recv'
}

Event System

The SDK uses Node.js EventEmitter for lifecycle events across all classes.

Typed Events

All event emitters are strongly typed:

import { ZapTransport } from '@hanzo/zt';

const transport = new ZapTransport({ /* config */ });

// TypeScript knows the event payload types
transport.on('authenticated', (info) => {
  // info is typed as { identityId: string }
  console.log(info.identityId);
});

transport.on('error', (err) => {
  // err is typed as Error
  console.error(err.message);
});

One-time Listeners

Use once() to wait for a single event:

// Wait for connection to be ready
await new Promise<void>((resolve, reject) => {
  transport.once('connected', resolve);
  transport.once('error', reject);
});

Async Iterator Pattern

For streaming frame processing, combine events with async generators:

async function* frames(transport: ZapTransport) {
  while (transport.isConnected()) {
    try {
      yield await transport.recvFrame();
    } catch (err) {
      if (err instanceof ConnectionClosedError) return;
      throw err;
    }
  }
}

// Usage
for await (const frame of frames(transport)) {
  const message = JSON.parse(frame.toString('utf-8'));
  console.log('Received:', message);
}

Removing Listeners

const handler = (frame: Buffer) => {
  console.log(`Frame: ${frame.length} bytes`);
};

transport.on('frame', handler);

// Later: remove the listener
transport.off('frame', handler);

// Or remove all listeners for an event
transport.removeAllListeners('frame');

Complete Example

End-to-end example: resolve credentials, connect with billing, send/receive frames, and record usage.

import {
  HanzoAuth,
  ZapTransport,
  BillingGuard,
  InsufficientBalanceError,
  AuthError,
  ZtError,
} from '@hanzo/zt';

async function main() {
  // -- Step 1: Resolve Hanzo IAM credentials --
  const auth = await HanzoAuth.resolve();
  console.log(`Auth: ${auth.display()}`);

  const token = await auth.getToken();

  // -- Step 2: Create and connect transport --
  const transport = new ZapTransport({
    controllerUrl: 'https://zt-api.hanzo.ai',
    service: 'echo-service',
    token,
    billingEnabled: true,
  });

  // Listen for lifecycle events
  transport.on('authenticated', (info) => {
    console.log(`[event] Authenticated as ${info.identityId}`);
  });
  transport.on('session', (session) => {
    console.log(`[event] Session ${session.id} for ${session.service}`);
  });
  transport.on('billing', (billing) => {
    console.log(`[event] Billing check: ${billing.ok ? 'OK' : 'FAILED'}`);
  });
  transport.on('error', (err) => {
    console.error(`[event] Error: ${err.message}`);
  });

  await transport.connect();
  console.log(`Connected: ${transport.peerAddr()}`);

  // -- Step 3: Send a request --
  const startTime = Date.now();
  const request = Buffer.from(JSON.stringify({
    action: 'echo',
    payload: 'Hello, Hanzo ZT!',
  }));
  transport.sendFrame(request);

  // -- Step 4: Receive the response --
  const response = await transport.recvFrame();
  const elapsed = Date.now() - startTime;
  console.log(`Response (${elapsed}ms): ${response.toString('utf-8')}`);

  // -- Step 5: Close and record usage --
  const sessionId = transport.sessionId();
  await transport.close();

  const guard = new BillingGuard({
    commerceUrl: 'https://api.hanzo.ai/commerce',
    token,
  });

  await guard.recordUsage({
    service: 'echo-service',
    sessionId,
    bytesSent: request.length,
    bytesReceived: response.length,
    durationMs: elapsed,
  });

  console.log('Usage recorded. Done.');
}

main().catch((err) => {
  if (err instanceof AuthError) {
    console.error(`Authentication failed: ${err.message}`);
    console.error('Run `hanzo login` or set HANZO_API_KEY');
    process.exit(1);
  }
  if (err instanceof InsufficientBalanceError) {
    console.error(`Insufficient balance: ${err.balance}`);
    console.error('Add credits at https://console.hanzo.ai/billing');
    process.exit(1);
  }
  if (err instanceof ZtError) {
    console.error(`ZT error: ${err.message}`);
    process.exit(1);
  }
  throw err;
});

Testing

Running Tests

# Run all tests
npm test

# Run with verbose output
npm test -- --verbose

# Run a specific test file
npm test -- --testPathPattern=auth

# Run with coverage
npm test -- --coverage

Unit Test Examples

Auth resolution test:

import { HanzoAuth } from '@hanzo/zt';

describe('HanzoAuth', () => {
  it('resolves from HANZO_API_KEY env', async () => {
    process.env.HANZO_API_KEY = 'hk_test_abc123';
    const auth = await HanzoAuth.resolve();
    expect(auth.display()).toContain('API key');
    expect(auth.display()).toContain('...c123');
    delete process.env.HANZO_API_KEY;
  });

  it('creates from explicit token', () => {
    const auth = HanzoAuth.fromToken('eyJhbGciOiJSUzI1NiIs...');
    expect(auth.display()).toContain('Hanzo IAM');
  });

  it('throws AuthError when no credentials found', async () => {
    delete process.env.HANZO_API_KEY;
    delete process.env.HANZO_JWT;
    await expect(HanzoAuth.resolve()).rejects.toThrow('No credentials found');
  });
});

ZAP transport framing test:

import { ZapTransport } from '@hanzo/zt';

describe('ZapTransport', () => {
  it('encodes frames with 4-byte BE length prefix', () => {
    const payload = Buffer.from('test payload');
    const frame = ZapTransport.encodeFrame(payload);

    // 4-byte prefix + payload
    expect(frame.length).toBe(4 + payload.length);

    // Verify length prefix
    const length = frame.readUInt32BE(0);
    expect(length).toBe(payload.length);

    // Verify payload
    const decoded = frame.subarray(4);
    expect(decoded.toString()).toBe('test payload');
  });

  it('decodes frames from length-prefixed buffer', () => {
    const payload = Buffer.from('hello');
    const frame = Buffer.alloc(4 + payload.length);
    frame.writeUInt32BE(payload.length, 0);
    payload.copy(frame, 4);

    const decoded = ZapTransport.decodeFrame(frame);
    expect(decoded.toString()).toBe('hello');
  });

  it('validates config requires controllerUrl', () => {
    expect(() => new ZapTransport({
      controllerUrl: '',
      service: 'test',
      token: 'tok',
    })).toThrow('controllerUrl is required');
  });
});

Billing guard test:

import { BillingGuard, InsufficientBalanceError } from '@hanzo/zt';

describe('BillingGuard', () => {
  it('throws InsufficientBalanceError on zero balance', async () => {
    // Mock the Commerce API response
    const guard = new BillingGuard({
      commerceUrl: 'https://api.hanzo.ai/commerce',
      token: 'test-token',
    });

    // With a mocked fetch that returns zero balance
    try {
      await guard.checkBalance('paid-service');
    } catch (err) {
      expect(err).toBeInstanceOf(InsufficientBalanceError);
      expect((err as InsufficientBalanceError).balance).toBe(0);
    }
  });
});

Integration Testing

For integration tests against a real ZT controller, set the environment and run:

export HANZO_API_KEY='hk_test_...'
export ZT_CONTROLLER_URL='https://zt-api.hanzo.ai'
npm run test:integration

Dependencies

PackagePurpose
node:eventsEventEmitter base class for lifecycle events
node:bufferBinary frame encoding and decoding
node:fs/promisesReading auth files from filesystem
node:pathResolving ~/.hanzo/auth.json path
node:netTCP socket for ZT connections

The SDK has zero external runtime dependencies. It uses only Node.js built-in modules.

TypeScript Support

The SDK ships with complete type declarations. All public APIs are fully typed including event payloads, error classes, and configuration interfaces.

// All types are exported from the main package
import type {
  ZtConfig,
  UsageRecord,
  BalanceInfo,
  SessionInfo,
  ServiceInfo,
  ZtEventMap,
} from '@hanzo/zt';

Type-safe Event Handling

The ZtEventMap type ensures event handlers receive correctly typed payloads:

interface ZtEventMap {
  connecting: [];
  authenticated: [{ identityId: string }];
  session: [{ id: string; service: string }];
  billing: [{ service: string; ok: boolean }];
  connected: [];
  frame: [Buffer];
  error: [Error];
  close: [];
}

Strict Mode Compatibility

The SDK is written with strict: true and all strict* compiler options enabled. No any types leak from the public API surface.

On this page