jkunz 8288ff286d feat(client): export TypedSocket's connection preparation error
A failed or timed-out authorizationHeaderProvider rejects start() with
TypedSocketConnectionPreparationError. Re-exporting it and its failure code
type next to the TLS pin error lets consumers use instanceof without a
direct TypedSocket dependency.
2026-11-14 20:56:01 +00:00
2026-05-05 12:03:46 +00:00
2026-05-05 12:03:46 +00:00

@smarthome.exchange/api

🔌 Typed API client for connecting tools, CLIs, automations, and apps to a smarthome.exchange hub.

This package wraps the hub's authenticated HTTP and Node WebSocket boundaries. Each client is bound to one home and returns the exact response shapes from @smarthome.exchange/interfaces.

The device contract consumes interfaces 2.1.0, including native tcp inventory. Hub 3.0 keeps these seven RPC envelopes unchanged. Discover current tools with listTools() before submitting a plan: write availability intersects the owner's enabled integration domains, adapter permissions and authenticated scopes. The client does not configure adapters or grant controls.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm add @smarthome.exchange/api

Basic Usage

import { SmarthomeExchangeApiClient } from '@smarthome.exchange/api';

const client = new SmarthomeExchangeApiClient({
  hubUrl: 'http://localhost:8080',
  homeId: 'home:primary',
  authorizationHeaderProvider: async ({ abortSignal }) => {
    const accessToken = await yourCredentialProvider.getAccessToken({ abortSignal });
    return `Bearer ${accessToken}`;
  },
});

const snapshotResponse = await client.getHomeSnapshot();
console.log(snapshotResponse.availability, snapshotResponse.snapshot.home.name);

const deviceResponse = await client.listDevices({
  capability: 'light',
});
console.log(deviceResponse.devices.map((deviceArg) => deviceArg.name));

yourCredentialProvider is the application's configured credential service; the client does not perform sign-in or persist tokens. The asynchronous authorizationHeaderProvider is required. It runs once per HTTP request and before each explicitly started physical Node socket connection. It receives the immutable home, final transport endpoint, operation name, transport, and cancellation signal. A socket context uses method: 'connect' and transport: 'websocket', with the owning attempt and preparation deadline. Return the complete Authorization header value. Provider failure, an empty value, or invalid HTTP header syntax rejects the call without sending an anonymous request.

The constructor captures the home and provider from plain own data properties. Changing the original options or a filter while credentials are being obtained cannot change the prepared request. Every operation includes context: { homeId }; filter parameters cannot replace that context or supply trusted identity. Nested action inputs must contain finite, acyclic JSON data. Accessors, symbolic properties, and class instances are rejected without evaluating their getters, before credential preparation. Tool plans and calls cannot supply identity or grant fields. The home selects a resource. The server derives identity and checks home grants from the transport credentials.

Transport and Cancellation

HTTP calls use published TypedRequest 9.2 and ${hubUrl}/typedrequest. An answer outside 2xx rejects with TypedRequest's exported TypedRequestHttpError, which carries the status and the parsed response body. Credentials stay in HTTP headers outside RPC JSON. Header-bearing calls bypass the shared response cache, including when useCache: true, and reject redirects before credentials can be forwarded. Configure the final hub URL without embedded credentials, query parameters, or a fragment. HTTPS and server-managed browser CORS remain deployment responsibilities.

Every method accepts optional request options:

const abortController = new AbortController();
const snapshot = await client.getHomeSnapshot({
  abortSignal: abortController.signal,
  timeoutMs: 5000,
});
const devices = await client.listDevices({ room: 'kitchen' }, {
  abortSignal: abortController.signal,
});

Cancellation is checked before and after credential preparation and passed to TypedRequest. Cancellation settles the API request even when a credential provider ignores its signal; late provider results or failures are observed and cannot open a request. The provider still owns its own resources and must cooperate with the supplied signal to release them. The client cannot terminate foreign provider work or synchronous code. timeoutMs is TypedRequest's HTTP deadline, beginning after credential preparation and covering transport and response decoding. API operations set the owning transport’s maxRetries to zero: a server-requested retry raises TypedRequestRetryLimitError after the first attempt. HTTP abort and timeout do not establish whether an already admitted device action completed. Inspect its durable approval or audit outcome before another attempt.

Authenticated Node Socket Mode

await client.start();
try {
  const { agents, statuses } = await client.listAgents();
  console.log(agents.length, statuses.length);
} finally {
  await client.stop();
}

Node start() consumes published TypedSocket 10 and obtains an Authorization header before the WebSocket upgrade, including its transport compatibility handshake. Credentials stay outside URLs and RPC frames; upgrade redirects are disabled. Socket calls carry the captured home context, and the hub must enforce current subject grants on every message. The package-major handshake establishes compatibility only.

While started, methods use that authenticated socket and its bound subject; the header provider is not reevaluated for individual socket messages. To rotate credentials or change subjects, stop and explicitly start a new connection. Automatic reconnects are disabled, so denied upgrade admission settles immediately without repeatedly presenting stale credentials. Without a hub key pin, calls before startup and after successful stop use authenticated HTTP; a pinned client rejects them (see Hub Key Pinning). An in-flight socket request that fails or is stopped is never resent over HTTP. In socket mode, timeoutMs configures both the TypedRequest deadline and TypedSocket’s per-call timeout, with the owning maximum of 300000 milliseconds; omitting it retains the socket’s 30000-millisecond default. Larger HTTP deadlines are unavailable in socket mode.

Browser WebSocket APIs cannot supply Authorization upgrade headers. Their start() fails with the owning TypedSocketConnectionPreparationError, code callback-rejected, whose cause is a TypedSocketConnectionPreparationError with code unsupported-headers. The capability check runs before obtaining credentials or opening a socket. Use the authenticated HTTP methods and snapshot polling in browsers. TypedSocket 10 does not support Deno, so socket mode is not supported there. The hub has no published application cookie-ticket issuer/consumer contract; this client creates no ticket endpoint and sends no authentication RPC. Native Fetch CORS and credential policy remain server/application responsibilities.

TypedSocket requires TLS for remote Node sockets; insecure ws: is permitted only on loopback. Configure an HTTPS hub URL for remote socket use. Credential preparation has the owning ten-second default/maximum deadline; set constructor socketConnectionPreparationTimeoutMs to a positive integer no greater than 10000 to reduce it. Its signal aborts on timeout, cancellation, disconnect, and stop. A noncooperative provider cannot hold socket stop open or open a connection with a late credential result. A provider that fails or misses the deadline rejects start() with TypedSocket's TypedSocketConnectionPreparationError (code callback-rejected or timeout; the type TTypedSocketConnectionPreparationFailureCode lists every code). This package re-exports both, so instanceof works without a direct TypedSocket dependency.

Concurrent identical start()/stop() calls coalesce. Mixed lifecycle calls run in order, with at most 64 active and queued operations. stop() supersedes earlier queued starts and cancels an in-flight startup using TypedSocket's exported public startup-aborted error. A late-created socket remains owned until its exact cleanup succeeds. A cleanup failure retains that socket for a later stop() and blocks new startup; failed startup is retryable through a later explicit start().

Hub Key Pinning

A hub that serves its local listener over TLS with a self-signed certificate is trusted by its key instead of by a certificate authority. Pass the pin of the hub key as hubKeySha256 with an https: hub URL:

const client = new SmarthomeExchangeApiClient({
  hubUrl: 'https://192.168.1.10:8443',
  homeId: 'home:primary',
  hubKeySha256: 'hB3V5ryySb8VT4CKjozUKRHBW6gZuNoQWXWJBPSamxY=',
  authorizationHeaderProvider: async ({ abortSignal }) => {
    const accessToken = await yourCredentialProvider.getAccessToken({ abortSignal });
    return `Bearer ${accessToken}`;
  },
});

await client.start();
try {
  const snapshotResponse = await client.getHomeSnapshot();
  console.log(snapshotResponse.snapshot.home.name);
} finally {
  await client.stop();
}

The pin is the RFC 7469 pin-sha256 of the hub certificate's public key: the SHA-256 of its DER SubjectPublicKeyInfo as padded standard base64, 44 characters. The constructor rejects hex and every other form, and a pin with a hub URL that is not https:, with a TypeError. Compute the pin with openssl x509 -in hub.pem -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | base64, or with computePublicKeyPinSha256(certificatePem) from @push.rocks/smartcrypto. A hub that issues every certificate for the same key keeps its pin across renewals.

A pinned client sends every request over its started TypedSocket, opened with TypedSocket's Node.js option tls: { publicKeySha256 }. Each connection completes the TLS handshake and compares the hub's public key with the pin before it writes the upgrade request and its Authorization header; the pin replaces certificate authority and hostname verification. A hub presenting another key rejects start() with TypedSocket's TypedSocketTlsPinError (code public-key-mismatch, or certificate-missing), whose message carries no header or credential. TypedSocket prepares the connection's header before it connects, so the credential provider has been asked once by then, but the header is never written.

This package exports that error class and its code type, TTypedSocketTlsPinFailureCode, so instanceof works without a direct TypedSocket dependency. With public-key-mismatch, the error's presentedPublicKeySha256 holds the pin of the key the hub presented, in the same 44-character form as hubKeySha256; it is absent with certificate-missing and when the presented key cannot be read.

import { SmarthomeExchangeApiClient, TypedSocketTlsPinError } from '@smarthome.exchange/api';

try {
  await client.start();
} catch (error) {
  if (error instanceof TypedSocketTlsPinError && error.code === 'public-key-mismatch') {
    console.error(`The hub presented another key: ${error.presentedPublicKeySha256 ?? 'unreadable'}`);
  }
  throw error;
}

The presented pin comes from a connection that failed its pin check, so it identifies a key without vouching for it: anyone between the client and the hub can present their own key. Show it to the operator, have them verify the new key out of band, for example by computing its pin on the hub itself, and never pin presentedPublicKeySha256 automatically.

A pinned client has no HTTP path. A request before start() resolves, after stop(), or after a failed start rejects with an Error that names start(), and a snapshot watcher reports that error to onError. Pinning needs Node.js 20.16 or a later 20.x release, or Node.js 22.3 or later; browsers refuse it, so a pinned client cannot start there. A client without hubKeySha256 behaves as before.

Client Methods

Method Typed operation Returns
listDevices({ room?, capability? }) listDevices { devices }
listAgents() listAgents { agents, statuses }
listTools(filter?) listTools { tools }
getHomeSnapshot() getHomeSnapshot { availability, snapshot }
watchHomeSnapshot(callback, options?) local polling helper Async stop function
executeToolPlan(plan) executeToolPlan IToolPlanResult
listApprovals(filter?) listApprovals { approvals }
submitApproval(request) submitApproval Updated approval result

Approval Decisions

Approval decisions bind to the exact action digest returned by listApprovals():

const { approvals } = await client.listApprovals({ status: 'pending' });
const approval = approvals[0];
if (approval) {
  const result = await client.submitApproval({
    approvalId: approval.id,
    expectedActionDigest: approval.actionDigest,
    decision: 'approved',
  });
  console.log(result.approval.execution.status);
}

The hub decides whether the authenticated subject may approve the snapshotted action. The shared contract distinguishes decision state from approval.execution; an approved decision alone is not proof of a physical effect. An interrupted dispatch may be indeterminate. The API client does not automatically resubmit approval decisions or device calls.

Snapshot Watching

watchHomeSnapshot(callback, options?) owns polling through getHomeSnapshot(). The cancellable watcher contract requires @smarthome.exchange/api 1.0.0 or newer. It captures and validates plain options once: abortSignal terminates the watcher lifetime, timeoutMs sets each request deadline, intervalMs defaults to 5000 and must be an integer from 1 through 2147483647, and immediate: false defers its first poll. Accessors and unsupported properties are rejected before credential preparation.

Every poll has an owned cancellation signal passed to the credential provider and transport. The async stop function clears the timer, aborts the active request, and drains API-owned request work. External lifetime cancellation is terminal, and a pre-aborted watcher starts no timer or request. No canceled or stopped poll publishes a later callback/error or schedules another poll. A normal request deadline is reported to onError; polling continues unless the watcher is stopped. HTTP deadlines begin after credential preparation, as for direct requests; providers must bound their own credential work.

User callbacks and onError handlers may await their own stop function. Stop never waits for active user handlers, so user code cannot hold external close open; a handler already running may finish later. Likewise, cancellation settles credential preparation without waiting for a provider that ignores its signal, but provider-owned resources require the provider's cooperative cleanup. An ignored provider's late result never dispatches a request. Watcher stop does not stop the client's independently owned socket; stop the client separately when its socket is also application-owned.

The callback receives the same { availability, snapshot } response returned by getHomeSnapshot(). availability is live for a successfully served live hub response and stale when the response carries stale snapshot data. A raw IHomeSnapshot without this response envelope is not automatically live. Polling does not add a server push contract. Explicit refreshes use the existing getHomeSnapshot({ abortSignal, timeoutMs }) request options.

const watchLifetime = new AbortController();
const stopWatching = client.watchHomeSnapshot(
  async ({ availability, snapshot }) => {
    console.log(availability, snapshot.home.name, snapshot.devices.length);
  },
  {
    intervalMs: 5000,
    immediate: true,
    abortSignal: watchLifetime.signal,
    timeoutMs: 5000,
    onError: async (errorArg) => {
      console.error('Snapshot refresh failed:', errorArg);
    },
  },
);

// Later, when the UI or process is shutting down:
watchLifetime.abort();
await stopWatching();

Tool Plan Example

const result = await client.executeToolPlan({
  id: 'plan:dim-living-room',
  callerId: 'app:console',
  title: 'Dim living room lights',
  reason: 'The user requested a 35% living-room brightness level.',
  confidence: 1,
  calls: [{
    id: 'call:living-room-dim',
    toolId: 'device:homeassistant:light.living_room:brightness:write',
    callerId: 'app:console',
    input: { value: 35 },
  }],
});

for (const callResult of result.results) {
  if (callResult.status === 'failed') {
    throw new Error(callResult.errorMessage);
  }
  if (callResult.status === 'indeterminate') {
    console.error('Device outcome requires reconciliation:', callResult.errorMessage);
  }
  if (callResult.status === 'queuedForApproval') {
    console.log('Approval required:', callResult.approvalId);
  }
}

Use a tool identifier returned by listTools() for a currently normalized integration-owned entity. Do not construct identifiers from seeded demo device IDs: a device without a live integration owner cannot dispatch a physical write and fails closed. Inspect every per-call result because a successful executeToolPlan() request may still contain failed, indeterminate, suggested, or queuedForApproval outcomes.

The plan and call callerId fields remain part of the authoring contract. They do not grant authority: the server resolves the subject from HTTP authentication. Reuse stable plan and call identifiers when inspecting an uncertain operation; do not generate fresh identifiers to retry a possibly dispatched effect.

Scripts

pnpm test
pnpm build
pnpm buildDocs

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the LICENSE file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme MIT
680 KiB
0 Stars 3 Watchers 0 Forks
Languages
TypeScript 100%