Skip to main content

Sync node

import * as mod from "jsr:@nzip/lofi-node";

Self-hostable sync node for lofi apps: one daemon embedding a Jazz 2.0 sync server, iroh node-to-node transport, and a ticket-gated access layer.

Create a node, issue an app-connect ticket, and point a lofi app's JAZZ_SERVER_URL at the ticket URL:

import { createSyncNode } from "@nzip/lofi-node";

const node = await createSyncNode({
appId: crypto.randomUUID(),
backendSecret: "...",
adminSecret: "...",
dataDir: "./data",
access: "ticket",
});
const { ticket } = await node.issueTicket({ label: "phone" });
// the app seals `ticket` at rest and uses its URL as serverUrl

The CLI lives at the ./cli export (dx -A jsr:@nzip/lofi-node/cli), and in-process test helpers at ./testing.

classifyMutationError​

function

function classifyMutationError(code: string): MutationErrorClass

Classify a mutation-error code from a rejected batch fate — the code on MutationErrorEvent, on PersistedWriteRejectedError, or on a BatchFate of kind rejected.

Unknown codes classify as transient: the consumer keeps the write pending rather than compensating on a code it cannot interpret.

CLOSE_TICKET_REVOKED​

const

const CLOSE_TICKET_REVOKED: 4001;

WS close code sent when a ticket is revoked mid-session; the app should surface re-enrollment (see docs/app-ticket.md).

createSyncNode​

function

async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode>

Start a sync node: an embedded Jazz server, optional iroh mesh, and (in ticket mode) the public access gate. The returned SyncNode is the whole operational surface: tickets, pairing, status, stop.

decodeAppTicket​

function

function decodeAppTicket(text: string): AppTicket | null

Parse a ticket string; null on any malformed input (paste path — never throws).

encodeAppTicket​

function

function encodeAppTicket(ticket: AppTicket): string

Serialize an app ticket to its lofisync1. string form.

initConfig​

function

async function initConfig(dataDir: string, overrides: Partial<Pick<NodeConfig, "appId" | "listenPort" | "upstream" | "access" | "storage" | "publicUrl" | "relay">>): Promise<NodeConfig>

Create (or return the existing) daemon config; new inits default to ticket-gated access and SQLite storage, with generated secrets.

isPermanentMutationError​

function

function isPermanentMutationError(code: string): boolean

True when classifyMutationError classifies code as permanent — the write is rejected and compensation may run.

loadConfig​

function

async function loadConfig(dataDir: string): Promise<NodeConfig | null>

Read config.json (null when absent); v1 files are mapped to v2 in memory — persisted as v2 on the next explicit saveConfig.

looksLikeAppTicket​

function

function looksLikeAppTicket(text: string): boolean

Cheap prefix check — lets the CLI pair command reject app tickets with a pointed message (an app ticket is not a node-pairing ticket).

looksLikeTicket​

function

function looksLikeTicket(text: string): boolean

Cheap sanity check for a pasted ticket: single token, plausible length. Never throws; never a guarantee — the addon's parser is authoritative.

MeshUnavailableError​

class

class MeshUnavailableError extends Error {
constructor(reason: string);
}

The iroh mesh layer could not be brought up (missing addon, unsupported platform, wrong build). The Jazz server itself still works without it.

MUTATION_ERROR_CLASSES​

const

const MUTATION_ERROR_CLASSES: Readonly<Record<string, MutationErrorClass>>;

The registry of known mutation-error codes.

The pinned Jazz alpha's server can emit exactly four rejection codes through the batch-settlement path (source: jazz-tools crate at the alpha.53 publish commit; the code space is the set of string literals fed into BatchFate::Rejected, not a closed enum):

  • permission_denied — the store adjudicated the write and refused it. Despite the name this is the server's general write-denial code: policy denial under a stale client policy, content that fails the server's column validation, and schema-resolution failures all surface under it (the reason differs; the classification does not). Permanent.
  • transaction_conflict — a transactional batch whose staged row's visible parent frontier moved before the authority validated the seal. Observed in the harness: the fate is final and deterministic across reconnects; the engine does not rebase or retry. A retry is a new batch. Permanent.
  • permissions_head_missing — the store enforces permissions but has no published permissions head (schema deployed without permissions). Observed in the harness: a persisted, batch-correlated rejection on both verdict surfaces. Publishing permissions later does not revisit the fate. Permanent.
  • invalid_batch_submission — the sealed submission itself is malformed (empty member list, digest mismatch, rows off the declared branch, invalid row states, mixed direct/transactional rows). Every trigger is an envelope invariant the client runtime itself computes, and the one author-reachable shape (committing an empty transaction) throws synchronously at the call site — so the code is unreachable from the public API by construction. It stays out of the registry (classifying as transient) until a rejection can actually be observed: an unprovoked permanent entry could authorize compensation on a fate whose semantics were never demonstrated.

Writes that violate the CURRENT locally-known policy or schema never produce a code at all — the client runtime refuses them synchronously at the call site, before any batch exists.

expired (the store refused the write because it arrived past its declared deadline) is registered permanent: late arrival is final, since waiting or re-sending only arrives later still. No store emits it yet, so the entry is inert until one does. The pinned alpha does use the string expired on a different surface — the transport's unauthenticated response, for a rejected JWT — but that surface never reaches this classifier.

saveConfig​

function

async function saveConfig(dataDir: string, config: NodeConfig): Promise<void>

Write config.json, owner-only (creates the data directory when needed).

validateRelay​

function

function validateRelay(relay: RelayConfig): void

Throws with the offending value when a relay election is malformed: custom mode needs at least one parseable http(s) relay URL.

validateStorage​

function

function validateStorage(storage: StorageConfig): void

Throws with an actionable message for storage types jazz-napi cannot back today (sqlite and memory are the supported set).

AppTicket​

interface

interface AppTicket {
v: 1;
appId: string;
url: string;
scope?: "sync" | "provision";
label?: string;
node?: string;
}

AppTicketInfo​

interface

interface AppTicketInfo {
id: string;
scope: "sync" | "provision";
label?: string;
parentId?: string;
createdAt: string;
revoked: boolean;
}

Public view of an issued app-connect ticket (never the secret).

MeshStatus​

type

type MeshStatus = { state: "up"; nodeId: string; ticket: string; connections: TunnelConnStat[] } | { state: "off" } | { state: "unavailable"; reason: string };

iroh mesh state: up (with pairing ticket + live tunnel stats), off, or unavailable with the precise reason (no silent degradation).

MutationErrorClass​

type

type MutationErrorClass = "permanent" | "transient";

How a consumer must treat a mutation-error code.

NodeConfig​

interface

interface NodeConfig {
v: 2;
appId: string;
backendSecret: string;
adminSecret: string;
listenPort?: number;
access: "open" | "ticket";
storage: StorageConfig;
publicUrl?: string;
upstream: UpstreamConfig;
relay?: RelayConfig;
allowLocalFirstAuth: boolean;
}

Persisted daemon configuration (<dataDir>/config.json, version 2 — v1 files migrate lazily on load).

RelayConfig​

type

type RelayConfig = "n0" | "disabled" | { urls: string[] };

Which relay infrastructure the node's iroh endpoint uses. Relays assist holepunching (address discovery) and carry traffic only when a direct connection cannot be established. "n0": the public n0-computer relays — rate-limited, no SLA, blessed for development and testing. { urls }: operator-run or dedicated relay servers (iroh-relay); the chosen relay travels inside this node's pairing tickets, so peers need no matching config. "disabled": no relays at all — direct connections only, which also forgoes relay-assisted address discovery.

StorageConfig​

type

type StorageConfig = { type: "sqlite"; path?: string } | { type: "memory" };

Where node data lives. jazz-napi supports exactly these two today; the type discriminator is the seam future providers slot into. path may be any mounted location (NAS, synced volume) — validated writable at boot.

SyncNode​

interface

interface SyncNode {
url: string;
port: number;
appId: string;
ticket(): string;
issueTicket(options?: { label?: string; publicBase?: string; scope?: "sync" | "provision" }): Promise<{ id: string; ticket: string }>;
revokeTicket(id: string): Promise<boolean>;
listTickets(): Promise<AppTicketInfo[]>;
pair(peerTicket: string): Promise<void>;
status(): SyncNodeStatus;
stop(): Promise<void>;
}

A running sync node — see createSyncNode.

SyncNodeOptions​

interface

interface SyncNodeOptions {
appId: string;
backendSecret: string;
adminSecret: string;
dataDir?: string;
inMemory?: boolean;
listen?: { port?: number };
access?: "open" | "ticket";
storage?: StorageConfig;
publicUrl?: string;
upstream?: UpstreamConfig;
mesh?: "auto" | "off";
relay?: RelayConfig;
allowLocalFirstAuth?: boolean;
irohLibPath?: string;
}

Options for createSyncNode.

SyncNodeStatus​

interface

interface SyncNodeStatus {
appId: string;
access: "open" | "ticket";
jazz: { url: string; port: number; storage: StorageConfig };
tickets: AppTicketInfo[];
gatedConnections: { ticketId: string; connections: number; lastSeenAt?: string; label?: string }[];
upstream: UpstreamConfig;
mesh: MeshStatus;
}

Snapshot returned by SyncNode.status.

UpstreamConfig​

type

type UpstreamConfig = "none" | { url: string } | { peer: string };

Where this node replicates to: nowhere, a direct Jazz server URL (e.g. Jazz Cloud), or a peer lofi-node named by its iroh pairing ticket.