SHX_LOCAL_OWNER=1 with SHX_STATE_DIR and SHX_HOME_ID: an embedded file-backed NoSQLDB engine under the 0700 state dir, a once-created 32-byte config key, a first owner with a generated password written 0600 (scrypt verifier stored), in-memory 12 h bearer sessions through /auth/local/session, a sign-in page, a LAN-only listen host (loopback, RFC1918, IPv6 ULA) and an offline reset-owner subcommand. The default CLI path is unchanged.
@smarthome.exchange/hub
🧠 Local smarthome.exchange runtime for device tools, scoped agents, automations, approvals, audit receipts, integrations, and typed hub handlers.
The hub is the composition root. It wires together registries, approval queues, audit logs, automation execution, MCP descriptors, integration discovery, and TypedRequest handlers around the shared contracts from @smarthome.exchange/interfaces.
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/hub
Basic Usage
import { ShxHub } from '@smarthome.exchange/hub';
const hub = new ShxHub({
home: {
id: 'home:studio',
name: 'Studio',
timezone: 'Europe/Berlin',
},
});
await hub.start();
const snapshot = hub.getSnapshot();
console.log(snapshot.devices.map((deviceArg) => deviceArg.id));
await hub.stop();
ShxHub registers typed handlers on hub.typedrouter. Host applications can bind that router to the transport they want to expose.
For a local standalone HTTP host, use ShxHubHost. It starts ShxHub, creates an @api.global/typedserver TypedServer bound to 127.0.0.1:8080 by default, assigns hub.typedrouter to one instance-local authenticated HTTP surface, exposes typed calls at /typedrequest, and serves the bundled console from dist_serve when it is available.
import { ShxHubHost } from '@smarthome.exchange/hub';
const host = new ShxHubHost({ host: '127.0.0.1', port: 8080 });
await host.start();
console.log(`Hub available at ${host.origin}`);
await host.stop();
host is the listener interface, not a public service hostname. Set it explicitly to 0.0.0.0 or :: only when the surrounding network policy requires a wider bind. For wildcard listeners, origin remains a usable loopback URL and its hostname is always admitted by the owned surface. Add exact reverse-proxy or private service authorities through surfaceHostnames; they supplement rather than replace the origin hostname. Unknown authorities receive 404. Direct TypedServer TLS options are reflected as https only when both key and certificate are configured.
ShxHubHost instances are single-use. Calling stop() before start() permanently stops that host, and a start attempted while stop is in progress is rejected. After stop(), create a new host instance instead of restarting the stopped instance. A stopped ShxHub is also single-use: restart with a new hub and a new persistence instance over the same database. Authentication capabilities and durable owner epochs belong to their original instance. Hosts with distinct hubs can run concurrently, and only one host may own a hub's lifecycle at a time. Stop calls TypedServer cleanup immediately even when host start is still pending. A stop attempt rejects after resourceCleanupTimeoutMs, defaulting to 15 seconds. While cleanup is pending, later stops await the same exact promise. If cleanup rejects, that attempt is cleared so a later stop can retry, while the TypedServer and per-hub ownership remain retained until cleanup succeeds. TypedServer 15 bounds its own component cleanup while ensuring a stalled component does not prevent listener cleanup. Caller-supplied typedServerOptions.surfaces are rejected because ShxHubHost owns its surface configuration.
The package CLI uses the same host:
export SHX_REQUIRED_INTEGRATIONS=your_domain
export SHX_INTEGRATIONS_JSON='[{"domain":"your_domain","config":{}}]'
export SHX_HOME_ID='home:studio'
export SHX_LISTEN_HOST='127.0.0.1'
# Add exact private proxy authorities only when they differ from the listen origin.
export SHX_SURFACE_HOSTNAMES='hub.private.local'
pnpm exec shx-hub
Installed packages expose shx-hub for the host and shx-hub-accept-homeassistant for read-only live acceptance. From this repository, pnpm start is an alias for the packaged host command. SHX_LISTEN_HOST defaults to 127.0.0.1; set it to 0.0.0.0 or :: only when the container or network policy requires a wider bind. SHX_SURFACE_HOSTNAMES supplies comma-separated additional exact authorities. ShxHubHost.port, PORT, and --port=<port> accept only decimal integers from 1 through 65535; an explicit malformed, zero, fractional, or out-of-range value stops startup.
SHX_INTEGRATIONS_JSON must be a JSON array of { "domain": string, "config": unknown } entries. The CLI starts each configured runtime through setupIntegration(...) and exits if parsing, setup, or cleanup fails. The CLI disables the seeded demo home and devices. It constructs a fresh Home Assistant adapter. Other native domains require a configured embedded host with fresh adapter instances, their credential providers or encrypted persistence, and adapter-specific owner permissions as described below. An unconfigured domain fails with an explicit configuration requirement; the hub never sets up the shared default-registry instances.
Controls default to denied. SHX_INTEGRATION_CONTROL_DOMAINS=homeassistant explicitly enables that domain at the hub boundary; IShxHubOptions.integrationControlDomains is the corresponding embedding option. This owner selection is independent of bootstrap configuration, discovery and authenticated grants. It does not grant an adapter's own permissions or a caller's write scopes.
For the managed first-instance path, setting any of HOME_ASSISTANT_URL, HOME_ASSISTANT_TOKEN, SHX_HOME_ASSISTANT_ACCEPTANCE_ENTITIES_JSON, SHX_HOME_ASSISTANT_SETUP_RETRY, SHX_HOME_ASSISTANT_INITIAL_SNAPSHOT_TIMEOUT_MS, or SHX_INTEGRATION_SETUP_TIMEOUT_MS activates strict Home Assistant bootstrap. The explicit SHX_HOME_ID, URL, token, and exact five-role entity JSON must then all be valid. The configured home ID is preserved exactly and selects both the hub home and Home Assistant device scope; it is never inferred or sanitized. Without managed Home Assistant, the CLI uses SHX_HOME_ID when supplied and otherwise retains its neutral home:unconfigured identity. The CLI adds homeassistant to the required domains, derives the exact entity allowlist, requires entity-registry provenance, applies the validated setup timeout, and redacts the token from startup errors. Do not also declare homeassistant in SHX_INTEGRATIONS_JSON; normalized duplicate domains are rejected before the host is constructed.
Readiness fails closed until at least one required integration domain is configured. SHX_REQUIRED_INTEGRATIONS supplies comma-separated generic domains, while managed Home Assistant bootstrap adds homeassistant automatically. GET /livez and GET /live report process liveness; GET /healthz and GET /health return 503 until every required integration has completed a healthy authoritative device synchronization. A bootstrap entry only works when that domain has a real runtime; descriptor-only integrations fail setup. These transport endpoints do not authenticate callers.
The served console defaults to an empty unavailable state and constructs no API client. The served /bundle.js ES module exports bootstrapShxHubConsole({ homeId, authorizationHeaderProvider, hubUrl? }); an operator-owned browser entrypoint calls it with explicit authentication; hubUrl defaults to that page's origin. It binds the catalog's shared ShxConsoleApiController and owns shell replacement and unload cleanup. Its returned session exposes refresh() and asynchronous close(). The shared controller owns authorized snapshot polling, clears stale home data on failure, and submits only the digest carried by the exact rendered approval review. The host serializes replacement by closing its previous session before creating another; concurrent bootstrap attempts fail explicitly. Failed cleanup retains the owned session for an explicit retry. With an operator-supplied identity provider, credentials remain with that provider and are never serialized or persisted by the host. The local-owner mode below is the exception: the host itself generates and persists the owner password, its verifier and the integration config key.
Local single-home owner mode
SHX_LOCAL_OWNER=1 turns the packaged CLI into a usable single-home instance for a local network. The default CLI is unchanged without it. The mode requires two explicit settings and has no defaults: SHX_STATE_DIR, an absolute private state directory, and SHX_HOME_ID. SHX_STATE_DIR without SHX_LOCAL_OWNER=1 is refused.
export SHX_LOCAL_OWNER=1
export SHX_STATE_DIR="$HOME/.local/state/smarthome-exchange"
export SHX_HOME_ID='home:studio'
# The hub's own LAN address; wildcard, public and CGNAT (100.64.0.0/10) addresses are refused.
export SHX_LISTEN_HOST='192.168.1.10'
export PORT=8080
pnpm exec shx-hub
SHX_LISTEN_HOST must be an address literal in loopback (127.0.0.0/8, ::1), RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) or IPv6 unique-local (fc00::/7) space. 0.0.0.0, ::, hostnames, link-local, CGNAT and public addresses stop startup. The browser origins allowed for sign-in and typed requests are http://<listen address>:<port> plus the same origin for every SHX_SURFACE_HOSTNAMES entry, for example a hub.home.arpa name that resolves to the hub.
The state directory holds only:
| Path | Content |
|---|---|
nosqldb/, nosqldb.sock |
The embedded file-backed NoSQLDB engine (@lossless.org/nosqldb) and its private socket. All hub data access goes through the @lossless.org/client/nosqldb models. The engine locks its storage root, so a second process on the same directory is refused. |
config-key |
The 32-byte key that encrypts stored integration configuration, created once and never replaced. |
owner-credentials |
The generated owner password (username=owner, password=…). |
The directory must be mode 0700 and the files 0600, all owned by the hub's user. A missing directory is created with mode 0700. A directory or file that other users can access, a symbolic link or a file owned by another user stops startup with an error naming the exact chmod, chown or readlink command that fixes it; the hub never changes permissions itself. Back up the whole directory together: the config key protects only copies of the database taken without it, and gives no protection once the state directory itself is readable.
On first start the hub generates a random 192-bit owner password, writes it to owner-credentials (mode 0600) and stores only its scrypt verifier (N=2^15, r=8, p=3) in the database. A first start interrupted after writing the file completes from that file on the next start. Later starts reuse the owner; the hub prints the credentials file path, never the password. Open http://<listen address>:<port>/, sign in as owner with the password from that file, and the console opens bound to the issued bearer token. Tokens live only in the hub's memory for 12 hours and end on restart; the browser keeps its token in page memory and sessionStorage for that tab, never in localStorage. POST /auth/local/session signs in with a JSON { "password": … } body, GET checks a bearer token and DELETE signs it out.
This mode serves plain HTTP: the password and bearer tokens travel in cleartext on the local network. Use it only on a network you trust.
To issue a new password, stop the hub and run the reset with the same environment:
pnpm exec shx-hub reset-owner
The reset works only while the hub is stopped, because it must take the database lock the running hub holds. It writes the new password through a temporary file and a rename (mode 0600), replaces the stored verifier and prints only the file path. After an unclean stop, the durable home lease blocks a restart for up to 30 seconds.
Secure Runtime and Breaking Migration
The secured runtime requires an explicit authentication configuration and durable persistence before callers can operate tools. authentication supplies an exact audience and an IShxIdentityProvider; its authenticate({ request, audience, signal }) validates transport credentials, and resolveIdentity({ subjectId, audience, signal }) returns current home grants. Both return a verified identity with subjectId, kind, homeGrants, the exact audience, and a future numeric expiresAt in milliseconds. The hub never treats identity objects, plan callerId, or call callerId in JSON as authority. The resolved subject supplies every action and audit attribution. Providers must honor cancellation and must return explicit identity fields without credentials or tokens.
ShxHubAuthentication.authenticateRequest(request) issues an immutable opaque context. Copied, manufactured, expired, or other-instance contexts fail authentication. withSignal(context, signal) derives a cancellation-scoped handle without extending its authority; resolveIdentity(context) rechecks current grants. ShxHubRequestService.invoke(method, unknownPayload, context) is the shared TypedRequest and MCP boundary. All seven methods require context: { homeId }; a home ID selects an existing grant and cannot grant access. Inputs are bounded finite JSON followed by compiled runtime schemas. Unknown fields, accessors, symbols, cycles, sparse arrays, nonfinite numbers and malformed approval digests fail before execution.
Tool access intersects the home grant and, for agent identities, the enabled agent's scopes and execution mode. *.read permits read scopes only. Unsupported camera writes are absent because the shared contract has no camera write permission. listTools returns the authorized catalogue. Device reads require device.read, agent listings require agent.invoke, and approval listings and decisions require approval.write. The complete home snapshot requires all three because it includes devices, agents and approvals.
Tool plans now require the hub-issued context when calling toolRegistry.executePlan(plan, context) directly. An ask action stores a detached immutable home/subject/subject-kind/plan/call/input/scope/tool-revision snapshot and its canonical SHA-256 digest. Approval submission requires that exact expectedActionDigest. The confirmed durable transaction alone grants one dispatch attempt; approval decisions execute the snapshotted call through the same authorization and schema rules. A changed tool or integration configuration invalidates the previous revision. Original caller grants, exact identity kind and agent mode are checked again before dispatch. An automatic claim requires the current mode to remain auto; a change to ask fails that claimed action without an effect. An approved claim may execute under auto or ask, while suggest never dispatches. Repeated operation identities return their existing approval/outcome instead of dispatching again. Unknown physical outcomes remain indeterminate, and must not be automatically retried.
ApprovalQueue mutation and listing methods are asynchronous and use the durable repository. Its detached display cache supports synchronous getSnapshot(); authenticated getHomeSnapshot reads the persistent snapshot. Durable device restoration marks devices offline until their connector completes an authoritative synchronization. Supplied integration configuration must be finite JSON and is stored as an immutable encrypted version; its manifest pointer and successful device snapshot activate in one fenced transaction. Restart restores the same version so pending approvals retain their binding. Integrations using the legacy omitted undefined configuration have no supplied configuration to restore; use the adapter's canonical finite JSON configuration, including {} for an adapter requiring no fields.
Plans validate every known call before execution and recheck current authority before each call. If later admission fails, completed results remain in the response and remaining calls fail before dispatch. An effect whose durable outcome receipt is unavailable returns an indeterminate result; callers must never automatically retry that effect.
ShxHubHost authenticates native HTTP TypedRequests through TypedServer's trusted admission grants. Client localData cannot replace the authenticated context. The native request body limit is at most 256 KiB, supplied origins must match allowedOrigins (the host origin by default), and authentication errors contain sanitized stable codes. WebSocket upgrades authenticate through the same provider and origin policy. The host privately binds each accepted capability to its exact native upgrade context. Each incoming WebSocket request resolves its verified physical peer through TypedSocket, derives a request-cancellation-scoped capability and invokes the same request service. Client wire identity and local data cannot supply that capability. Use the published TypedSocket connection preparation API for Node authorization headers; browser clients need a provider-supported authenticated cookie or ticket flow. Without SHX_LOCAL_OWNER=1, the packaged CLI provides no identity provider or database binding; it serves static/liveness surfaces and its browser starts unavailable. A configured embedded host and explicit authenticated browser bootstrap are required for secured data calls. No OIDC, cookie or ticket flow is inferred. The opt-in local-owner mode supplies a built-in password sign-in and bearer tokens for one local owner; other authentication provisioning and deployment remain separate operator operations. ShxHubHost({ signIn }) mounts such a trusted first-party sign-in endpoint on the host's own surface, behind the host's lifecycle and origin policy and with its declared body limit.
Calling stop() permanently closes hub admission, invalidates its authentication capabilities, drains owned tool and connector operations, and closes durable ownership. Failed startup performs the same terminal cleanup. A later start() rejects explicitly. Restart using fresh hub and persistence instances over the same database. Pending approvals survive in storage; interrupted previously claimed effects recover as indeterminate and are never redispatched.
Runtime Pieces
| Export | Responsibility |
|---|---|
ShxHub |
Composition root for the local runtime. |
ShxHubHost |
Local standalone TypedServer host for ShxHub. |
DeviceRegistry |
Lists, looks up, and reconciles integration-owned device snapshots. |
AgentRegistry |
Holds reference agents and runtime statuses. |
ToolRegistry |
Authorizes tool plans and executes immutable calls according to tool and agent modes. |
ApprovalQueue |
Delegates approval creation and decisions to durable storage and retains a detached display cache. |
AuditLog |
Retains a bounded display window of durable audit receipts. |
AutomationRunner |
Owns an SDK automation context wired to the tool registry. |
McpDescriptor |
Maps tool IDs to stable MCP-safe aliases and operation argument schemas. |
McpEndpoint |
Serves authenticated modern MCP requests through the shared hub request service. |
MCP Endpoint
McpEndpoint exposes a Fetch handler for the 2026-07-28 MCP protocol using the official TypeScript SDK. Configure an exact resource URL, the same authentication audience, the selected home, authorized browser origins, and the actual authorization-server metadata. Authentication uses the hub's private authenticated request capability. Tool discovery and every tool call use hub.requestService, including its current home grants, scoped authorization, runtime schema checks, and approval policy.
import { McpEndpoint } from '@smarthome.exchange/hub';
if (!hub.authentication) throw new Error('Configure hub authentication before serving MCP.');
const endpoint = new McpEndpoint({
authentication: hub.authentication,
requestService: hub.requestService,
homeId: hub.home.id,
resourceUrl: 'https://hub.example/mcp',
authorizationServers: ['https://identity.example/'],
allowedOrigins: ['https://console.example'],
});
const response = await endpoint.fetch(request);
// During host shutdown, await endpoint.close() before releasing the HTTP listener.
Configure ShxHubHost({ mcp: { resourceUrl, authorizationServers, allowedOrigins } }) to mount the MCP path and its /.well-known/oauth-protected-resource/mcp metadata route on the same owned HTTP surface. The host supplies its actual authentication, request service and home; callers cannot replace those capabilities. Native body intake preserves stricter caller limits and applies the endpoint limit to both MCP paths. Native connection, headers and request deadlines are at most the configured endpoint deadline. Shutdown closes the endpoint alongside the listener and hub, retaining failed cleanup for retry. The metadata route publishes only explicitly configured resource and issuer URLs. A missing or invalid credential receives a safe bearer challenge pointing to that metadata. Non-loopback resource and issuer URLs require HTTPS; credentials, query strings, and fragments are rejected. An exact URL authority and an allowlist of origins protect the MCP route. The Fetch handler is a library surface; constructing it does not open a listener or register an identity-provider client.
Tools use deterministic shx_tool_<sha256> aliases so punctuation and long IDs cannot collide through replacement or truncation. Each tool's displayed title and description come from the current authorized catalog. Calls provide { input, operationId }, where input matches the original tool schema and operationId is a stable 1–128 character identifier containing only letters, digits, ., _, :, or -. The home, authenticated subject, full tool ID, and operation ID determine the plan/call identity. Reusing an operation ID for a changed action or identity kind is a conflict enforced by the hub's immutable action digest. Confirmed automatic and approval-gated operations retain one dispatch authority and an atomic durable outcome; uncertain physical effects cannot be safely retried, so MCP descriptors set idempotentHint: false, and clients must never automatically retry an uncertain write.
An approval-requiring call returns the shared queuedForApproval result with its approval ID. Decisions use the secured hub API or CLI and the exact action digest. MCP elicitation, client-supplied caller IDs, and transport request IDs cannot approve or impersonate a caller. Every call resolves its alias against a freshly authorized catalog, so revoked scopes or removed tools fail before execution. Cancellation before effect admission reaches the authenticated request signal; once the hub claims a dispatch, the hub owns its physical continuation and receipt independently of caller disconnects.
The endpoint bounds each accepted Fetch exchange to 262,144 request bytes, 30 seconds, and 64 concurrent requests by default. Configure maxRequestBodyBytes, requestTimeoutMs, and maxConcurrentRequests when the host requires different bounds. The SDK owns JSON parsing and byte limits. The endpoint owns its accepted body reader, deadline, response stream, and per-request server cleanup. The HTTP host must also bound its own earlier body intake: for example, the official Node adapter buffers and limits the Node stream before constructing a Fetch request. Endpoint shutdown cancels accepted readers, aborts pending transport exchanges, and drains server instances. Cleanup failures retain ownership, refuse new requests, and are reported by close().
Legacy 2025 protocol requests are explicitly rejected through the SDK's supported legacy: 'reject' option. The SDK's stateless legacy serving cannot route a separate cancellation notification to the original per-request server. This prevents relying on cancellation before admission; legacy operation serving remains deferred until the owning SDK or a verified sessionful transport provides that contract. The SDK request body contract covers bounded text reads and propagating stream failures; the HTTP handler contract documents per-request serving and the supported legacy rejection option.
State And Read Ownership
Device read tools are owner-bound: the tool's ownerId always selects the device. Canonical read calls provide empty input ({}). For compatibility with the published SDK, a read may also contain only a deviceId exactly matching that owner. Mismatched targets and every other input are rejected rather than being allowed to redirect the operation.
Configure home data through new ShxHub({ home: ... }). The public hub.home property is a read-only getter that returns a detached snapshot. The hub also detaches constructor input, getSnapshot(), and registry, approval, and audit list/get output, including nested arrays and objects. Mutating those returned values cannot change subsequent hub state or snapshots.
Typed Operations
registerTypedHandlers() installs handlers for:
| Operation | Purpose |
|---|---|
listDevices |
Return normalized devices from the device registry. |
listAgents |
Return reference agents and runtime statuses. |
listTools |
Return registered device and agent tools. |
getHomeSnapshot |
Return canonical live availability with home, devices, agents, approvals, dashboards, receipts, and timestamp. |
executeToolPlan |
Execute a plan, queue approval, suggest, fail, or return an indeterminate result per tool call. |
listApprovals |
Return approval requests filtered by status. |
submitApproval |
Decide an approval with its immutable action digest; a confirmed one-use claim dispatches an approved call once. |
auditReceiptLimit and approvalQueueLimit, both defaulting to 1,000, bound process-local display caches. Cache eviction does not remove durable approvals or append-only receipts. Authenticated approval lists and home snapshots read the repository. A full durable pending-approval capacity returns a failed tool result for a new approval-requiring call before any dispatch.
Durable Home State
ShxHubPersistence binds one home to a caller-owned @lossless.org/client/nosqldb database. Supply an explicit home definition on first initialization; subsequent instances load its stored definition. The first versioned migration installs the durable schema and records completion. Volatile state from older hub versions has no recoverable history.
import { ShxHubPersistence } from '@smarthome.exchange/hub';
const persistence = new ShxHubPersistence({
db, // An initialized, caller-owned SmartdataDb.
homeId: 'home:office',
configKeyProvider, // External currentKey/keyForId provider, with 32-byte encryption keys.
});
await persistence.initialize(homeDefinition);
try {
const revision = await persistence.saveIntegrationConfig('mqtt', mqttConfig, devices);
const stored = await persistence.readIntegrationConfig('mqtt');
// stored.config contains decrypted config; stored.revision remains stable until a new save.
const snapshot = await persistence.loadPersistentSnapshot();
} finally {
await persistence.close();
// The caller closes db and its key provider separately.
}
A home has one durable owner token, monotonically increasing epoch and bounded lease. Every mutation changes that owner fence in the same transaction as its data. Unknown transaction commits revoke the owner signal and all dispatch authority. Schema preparation, transaction work and renewal are bounded; close stops admission, aborts owner work, drains admitted operations and releases only its own exact fence. Close also cancels and drains every hub-owned encrypted configuration store; failed cleanup retains that resource for retry. The caller retains ownership of the database.
Concurrent close() calls, including calls from an owner-signal abort listener, return the same promise for that cleanup attempt. A failed attempt keeps its original rejection; a later close() can retry cleanup after the caller restores its database connection. Failed terminal configuration-store cleanup uses the store's public retryCleanup() on that later attempt, including stores retained after preparation failed. This acknowledges settled provider failures without invoking the key provider again or replaying configuration operations; the store's original close() remains rejected. Owner release prepares its collection for the current connection and preserves the exact owner fence without reopening persistence or repeating configuration writes.
Approval actions retain their exact home, authenticated subject and kind, plan, call input, scopes and tool revision under a canonical SHA256 digest. Recreating the same action is idempotent only when its digest matches. A decision atomically records its receipt and changes the pending action to dispatching. Only a confirmed commit issues a privately registered dispatch claim; copied, forged, repeated and cross-instance claims fail. Admission consumes that claim once. Completion records its exact execution outcome with an append-only receipt. A new confirmed owner changes interrupted older dispatches to indeterminate, preserves their execution IDs and appends a recovery receipt. It never dispatches them again; a durable admission does not prove a physical device applied the command.
Automatic calls use the durable tool-operation ledger with the same home-owner fence and single-use dispatch claims. Their admission receipt commits with the immutable operation; terminal outcomes commit with their receipt. An operation identity selects the home, authenticated subject, plan and call. Repeating it requires an identical action digest, including the immutable subject kind, and current grants, and returns its existing outcome or an indeterminate in-progress result. A changed digest conflicts before dispatch. Automatic and approval-gated paths cannot separately reserve the same identity, including after a mode change. Caller cancellation before admission creates no operation; cancellation after a confirmed claim leaves continuation with the hub owner. Recovery never reissues a claim for an interrupted operation.
Integration configuration uses the integrations package's AES-256-GCM NoSQL store with external keys. Each save creates an immutable encrypted version and activates its scoped manifest pointer in the same fenced transaction; the optional complete device snapshot and redacted receipt share that transaction. Stored revisions bind approvals to the exact configuration used at admission. Configuration and keys never enter public home snapshots or audit summaries. The hub persistence itself stores no application state on the filesystem; only the opt-in local-owner mode keeps its embedded database, config key and owner credentials file in its state directory.
Device snapshots and pending approvals are bounded to 1,000 entries by default. Automatic operations remain persisted to preserve retry identity; maxActionOperations defaults to 10,000 and accepts 1 through 100,000. Reaching that limit rejects new operations before dispatch. Approval and receipt list methods return their newest configured window, while all decided approvals and audit receipts remain persisted. listReceipts({limit}) accepts 1 through 1,000. Recovery scans interrupted executions in bounded transactional batches.
Integration Flow
const integrationHub = new ShxHub({
requiredIntegrationDomains: ['homeassistant'],
seedDemoState: false,
});
await integrationHub.start();
try {
const runtime = await integrationHub.setupIntegration('homeassistant', {
homeId: integrationHub.home.id,
baseUrl: 'http://homeassistant.local:8123',
accessToken: process.env.HOME_ASSISTANT_TOKEN!,
entityIds: [
'light.living_room',
'sensor.sun2000_power',
'switch.fs20_lamp',
'binary_sensor.zha_front_door',
'vacuum.roborock',
],
requireEntityIds: true,
requireEntityRegistry: true,
});
console.log(await runtime.devices());
} finally {
await integrationHub.stop();
}
The first production instance uses this single Home Assistant aggregation runtime for Hue, Huawei SUN2000 telemetry, FS20 through CUL/FHEM-MQTT, SkyConnect/ZHA entities, and Roborock. Keep the long-lived token in deployment secret configuration rather than source. Native adapters have independent setup requirements; Home Assistant remains the aggregation path for this first-instance acceptance command.
Integrations come from @smarthome.exchange/integrations. Successful setup returns a runtime and reconciles the integration's full normalized device snapshot into the hub device registry. Events schedule a debounced authoritative snapshot refresh; absent devices are removed. Setup admission retains at most integrationSetupQueueLimit configurations, defaulting to 32, and rejects another setup for a domain that is already active or queued. Setup rollback keeps its owned state registered until pending refresh and write calls have settled through bounded lifecycle cleanup, and unresolved calls remain tracked so retries cannot orphan them. Failed runtime destruction remains retained by the integration manager and is retried through a bounded hub wait before any new same-domain runtime is created. Writable readable features are confirmed by polling authoritative state, while write-only features report accepted delivery without inventing state. Once a connector accepts a physical write, refresh failure, runtime replacement, cancellation, or non-convergence is reported as indeterminate, never failed. Integration setup has a separate 30-second default deadline, while Home Assistant uses one 15-second deadline for subscription acknowledgement, the first snapshot, and optional registry verification after connection retries. The 10-second steady-state operation deadline remains separate. All hub timer options reject non-integer, non-finite, negative, and Node-overflow values instead of allowing collapsed timers. Write admission and readiness both consult current runtime health, so a Home Assistant disconnect fails closed without waiting for the debounced registry refresh.
Trusted Native Integration Context
Select the operator home explicitly through new ShxHub({ home: { id, name } }) or SHX_HOME_ID. Hub display defaults remain display identities and cannot supply native integration authority. The hub captures the genuine selection once; later mutation of the options object, integration configuration, discovery data and persisted scope cannot change it. Supplied and restored configuration homes must match before runtime, store or replacement effects.
A started hub exposes await hub.getIntegrationContext(domain, abortSignal?) for trusted embedding callers. The frozen context carries the captured home and cancellation tied to caller, durable ownership and hub shutdown. Runtime setup uses this same capability under its setup deadline and pending-domain admission. This method creates no unauthenticated route and does not replace caller authentication or home grants.
The hub requires Node.js 22.20.0 or newer and consumes integrations 3.0.0, devicemanager 4.11.1 and interfaces 2.1.0, including native TCP device definitions. Generic contexts retain optional homeId for legacy integrations, while native setup and flow contexts require it. A trusted native-flow caller validates the captured selection before constructing its required-home context:
import type { INativeConfigFlowContext } from '@smarthome.exchange/integrations';
const context = await hub.getIntegrationContext('kodi');
if (context.homeId === undefined) {
throw new Error('Select an operator home before starting a native config flow.');
}
const nativeContext: INativeConfigFlowContext = Object.freeze({
...context,
homeId: context.homeId,
});
// Pass nativeContext to the selected native flow's start(candidate, context).
Follow the integrations 3.0.0 README and each adapter's domain README for its canonical configuration. Configurations, discovery candidates and stored scope cannot provide the missing home selection. Reconfigure stored legacy inline credentials explicitly; the hub does not translate them or infer permissions.
With configured encrypted persistence, context may contain the actual prepared NoSqlConfigStore already owned by the hub for that home/domain. Without its external key provider the store is absent; credential-reference or restoration paths still require their own persistence capability. Store, database and provider remain borrowed by adapters and flows. Closed or unprepared owners cannot expose a store. Hub stop publishes its stable terminal promise before abort callbacks; explicit failed cleanup can be retried, while successful close remains terminal.
Owner configuration for native adapters
Pass fresh integration instances through IShxHubOptions.integrations, or register them through hub.integrationRegistry.register(instance) before setup or flow work. Registration transfers their complete lifetime to this hub and refuses shared default instances or instances already owned by another hub. The package's default registry remains available for discovery metadata. A fresh Home Assistant instance is supplied automatically. No credential resolver or control grant is inferred from a registry descriptor.
import { SonosIntegration } from '@smarthome.exchange/integrations';
const sonos = new SonosIntegration({ allowControl: true });
const hub = new ShxHub({
home: { id: 'home:studio' }, seedDemoState: false,
integrations: [sonos],
integrationControlDomains: ['sonos'],
// Supply authentication and durable persistence for authenticated tool dispatch.
});
await hub.start();
try {
await hub.setupIntegration('sonos', {
homeId: 'home:studio', host: '192.168.1.50', expectedUid: selectedSpeakerUid,
});
} finally {
await hub.stop();
}
selectedSpeakerUid is an owner-selected observed identity. Hub enablement intersects the adapter's permission and current capability, plus authenticated home grants and write scopes. Disabled domains publish read-only canonical features and no write tools; the dispatch boundary independently rejects writes even if a writable catalogue is forged. Restored configuration cannot enable controls. Native acknowledgements and observations retain their original outcome data; no command replay or optimistic state is added.
Owner selections required by the native integration migrations are:
| Domain | Credential and device selection | Additional adapter control permission |
|---|---|---|
| All native domains | Explicit hub home, exact selected endpoint and the adapter's required observed identity; fresh owned instance | Explicit hub integrationControlDomains and authenticated write scopes |
| MQTT | credentialKey with constructor resolveCredentials, or explicitly credential-free broker; explicit plaintext LAN acknowledgement when applicable |
Discovery mode preserves its supported controls; selected-topics mode stays read-only |
| ESPHome | Observed node name/MAC, credentialKey with constructor resolveCredentials, or explicit credentialSource: 'store' and scoped encrypted storage |
Existing supported controls still require hub enablement |
| Shelly | Selected Gen2/3 reported identity and credentialRef with resolvePassword when needed |
Configuration allowControl: true |
| Sonos | Selected speaker UID and current topology/capability authority | Constructor allowControl: true |
| Z-Wave JS | Independent SHX home and selected Z-Wave network; credentialRef and explicit provider/config-store source for proxy authorization |
Configuration allowControls: true |
| deCONZ | Selected bridge ID and credentialReference, with constructor credentialProvider or matching encrypted store |
Exact constructor controlPermissions resource/ID/field entries |
| Hue (integrations 4.0.0) | Independent hub home.id, matching config homeId, selected bridge ID/HTTPS endpoint and credentialReference; constructor credentialProvider implementing IHueCredentialProvider or matching encrypted store |
Exact constructor controlPermissions with resource: 'lights', raw light UUID and fields on/brightness |
| Zigbee2MQTT | Explicit authentication, referenced broker credentials and selected IEEE addresses |
Exact constructor controls IEEE-address/property entries |
| Tasmota | Explicit anonymous or credential-reference authentication, matching resolver/store, selected device Topic/FullTopic |
Constructor enableControls: true |
| webOS | Selected TV identity and pairing record in matching encrypted storage; owner performs physical pairing | Constructor allowedCommands list |
Native Hue setup requires a fresh HueIntegration supplied through options.integrations. Its independently selected hub home is passed through the same frozen context used for deCONZ. Controls also require hub integrationControlDomains: ['hue'] and authenticated write scopes. Hue advertises supported light capabilities; its exact adapter grants are enforced at dispatch. Ungranted adapter writes issue no PUT, but the hub conservatively reports the adapter's plain exception as indeterminate; do not automatically retry it. An omitted hub control domain exposes read-only features and no write tools.
Integrations 4.0.0 refuses raw Hue appKey configurations, including encrypted configurations restored by hub startup. The migration error is surfaced unchanged and no key is automatically converted or resolved. Before starting a hub with legacy Hue state, initialize its ShxHubPersistence for the independently selected home and obtain the prepared getIntegrationConfigStore('hue'). Use the published Hue config flow with that home/store and an exact config grant for the selected bridge ID and credentials:create to insert the existing key once. Save only the successful flow's reference-only result.config through saveIntegrationConfig('hue', result.config), then close the migration integration and persistence before transferring fresh instances to the runtime hub. Existing references are never overwritten. Physical link-button pairing additionally requires pair. See ts/integrations/hue/readme.md in the published integrations 4.0.0 package for the complete flow contract.
Update saved Hue targets to hue.light.<encoded homeId>.<light UUID> for light devices, hue.bridge.<encoded homeId>.<bridge device UUID> for bridge devices, and light.hue.<encoded homeId>.<light UUID> for entities. This native migration does not change the Home Assistant aggregation acceptance contract: its Hue role still verifies Home Assistant platform hue, Home Assistant-owned targets and that adapter's power/brightness features. Persistence continues to admit the canonical hue device protocol.
There is no automatic provider/store fallback. Providers must honor their admitted cancellation and deadlines and settle actual work; the host owns their external resources. The hub closes its integration instances, including config-flow scopes and failed setup work, alongside runtimes. It waits for actual cleanup before closing its persistence. Failed cleanup stays retained; call hub.stop() again to explicitly retry cleanup through each adapter's retryCleanup() when available. Retry never sets up an adapter, retrieves credentials or replays a command. A successful stop is terminal.
hub.runNativeIntegrationOperation(domain, async (lease) => ..., abortSignal?) admits finite trusted host work through the published root NativeIntegrationOwner.runOperation API. The lease captures the independently selected home and optional prepared borrowed store. Register allocated resources with lease.own(cleanup) before foreign callbacks. Operations and retained failed cleanup share the host root's default 16 slots per domain; domain roots are bounded by integrationSetupQueueLimit. No permanent config-flow scope is allocated. This helper is for finite host work such as insert-only credential-reference provisioning; native runtimes retain their own admission, capacity and control checks. It grants no authentication or effect permission. Cancellation joins admitted work and cleanup, and terminal hub shutdown rejects new admission.
Live Home Assistant Acceptance
The packaged acceptance command connects through the real hub path without calling services or actuating devices. It requires exact representative entity IDs, fail-closed readiness, online normalized devices, role capabilities, state evidence for every required readable feature, and Home Assistant entity-registry provenance: Hue must report platform hue, SUN2000 huawei_solar, FS20 through FHEM-MQTT mqtt, SkyConnect devices zha, and Roborock roborock.
export SHX_HOME_ID='home:studio'
export HOME_ASSISTANT_URL='http://homeassistant.local:8123'
# Supply HOME_ASSISTANT_TOKEN through a local or deployment secret, not source control.
export SHX_HOME_ASSISTANT_ACCEPTANCE_ENTITIES_JSON='{
"hue":["light.living_room"],
"huaweiSun2000":["sensor.sun2000_power"],
"fs20":["switch.fs20_lamp"],
"skyConnect":["binary_sensor.zha_front_door"],
"roborock":["vacuum.roborock"]
}'
pnpm exec shx-hub-accept-homeassistant
From this repository, pnpm accept:homeassistant is an alias for the packaged acceptance command. Each role must be present and non-empty; entity IDs must be canonical and unique across roles. Optional SHX_HOME_ASSISTANT_SETUP_RETRY, SHX_HOME_ASSISTANT_INITIAL_SNAPSHOT_TIMEOUT_MS, and SHX_INTEGRATION_SETUP_TIMEOUT_MS values are bounded integers, and the outer setup budget must cover the snapshot and retry budgets. Acceptance verifies canonical device IDs and metadata against the exact configured home and endpoint scope. Output is structured JSON and token values are redacted from failures. Physical write acceptance remains a separate, explicitly approved operation.
Seeded Demo State
By default, embedded ShxHub instances seed a Birch Lane demo home and devices for development. Pass seedDemoState: false for a neutral home with no demo devices; the package CLI does this automatically. Override home with the real home definition.
Console Bundle
The web entry imports @smarthome.exchange/catalog and renders <shx-console-shell> in an empty unavailable state. Explicit authenticated bootstrap binds the catalog-owned controller, which validates the complete API snapshot and maps canonical availability into the shell model. The bundle also exports mountShxHubLocalOwnerSignIn(); the local-owner mode's sign-in page at / calls it to show the owner sign-in form and then bootstrap the console with the issued token.
Cloudly Release Evidence
Set TSDEPLOY_RELEASE_AUTHORIZED=true before running pnpm release:evidence. The command consumes the exact source revision, version, configuration digest, OCI index digest, and output path supplied through the five required TSDEPLOY_* environment variables. It writes digest-bound descriptive evidence with mode 0600; Cloudly registry validation remains authoritative.
Scripts
pnpm test
pnpm build
pnpm watch
pnpm start
pnpm accept:homeassistant
pnpm release:evidence
pnpm buildDocs
License and Legal Information
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.